openvibers/openvibe.space Commits
SEO-7: a gated space page answers on openvibe.space with noindex, not by redirecting to Network sign-in (#116)
Changes
@@@ -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));@@@ -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 };@@@ -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/);
@@ -65,7 +65,12 @@ function createSpacesPages(ctx) {
@@ -65,7 +65,12 @@ function createSpacesPages(ctx) {
const csrfOf = (req) => (signedIn(req) ? csrfToken(config, req.viewer) : '');
const csrfOf = (req) => (signedIn(req) ? csrfToken(config, req.viewer) : '');
const html = (res, status, body) => res.status(status).type('html').send(body);
const html = (res, status, body) => res.status(status).type('html').send(body);
function failPage(req, res, err) {
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);
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;
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));
return html(res, status, render.errorBody(status, status === 500 ? 'Something went wrong on our side. Try again in a moment.' : err.message));
@@ -235,8 +235,22 @@ function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {
@@ -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 });
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) {
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>` });
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 };
@@ -55,6 +55,19 @@ function fakeAi() {
@@ -55,6 +55,19 @@ function fakeAi() {
assert.doesNotMatch(r.text, /name="template"/, 'no start form for someone signed out');
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 () => {
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' });
const r = await form(alice, '/spaces/new', { template: 'guestbook' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
@@ -75,7 +88,9 @@ function fakeAi() {
@@ -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}`, { 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}/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}`, { 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 } });
const forged = await t.api('POST', '/spaces/new', { session: alice, form: { csrf: 'nope', template: 'poll' }, headers: { origin: ORIGIN } });
assert.strictEqual(forged.status, 403);
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) } });
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() {
@@ -159,7 +174,7 @@ function fakeAi() {
let bobToken = null;
let bobToken = null;
await check('a visitor approves the space on the connect page and comes back with a one-time code', async () => {
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')}`;
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 });
const page = await t.api('GET', `/spaces/${slug}/connect?${q}`, { session: bob });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /Ask OpenVibe AI for you/);
assert.match(page.text, /Ask OpenVibe AI for you/);