openvibers/openvibe.community Commits
Carry share-link drafts into the forum's new-thread form (#333)
Changes
@@@ -351,6 +351,12 @@ Spaces hold threads; a thread is its opening post plus replies (Markdown). Seedereads, people post), `members` (signed-in people only; every Network account counts as amember for now), `staff` (moderators only; looks missing to everyone else).++**Share a draft into the forum.** Another OpenVibe site opens `/forum/discuss?title=…&body=…`++(optionally with `&paste=<id>`, which attaches a paste) and the picker carries it to++`/forum/<space>/new` with the fields filled in. Both are plain text, cut to 200 characters of++title and 40,000 of body and escaped when shown; the link never posts, and a signed-out visitor++signs in and comes back to the same prefilled form, served `private, no-store` and `noindex`.++- **Pages work without JavaScript** — sorting and pagination are links, replying, voting,pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` isrefused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++++/**++ * A draft another OpenVibe site may carry into the forum through a share link++ * (/forum/discuss?title=…&body=…). The new-thread form's own limits apply: title 200 characters,++ * body 40,000. Both are plain text; the renderer escapes them when it fills the form. A link never++ * posts: the person still reviews and submits the form themselves.++ */++const MAX_TITLE = 200;++const MAX_BODY = 40_000;++++/** The first string of a query value (Express hands an array for a repeated parameter). */++function text(value) {++ if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || '';++ return typeof value === 'string' ? value : '';++}++++/** The title and body a share link carries, cut to the form's limits. */++function draftPrefill(query = {}) {++ return {++ title: text(query.title).slice(0, MAX_TITLE),++ body: text(query.body).slice(0, MAX_BODY),++ };++}++++module.exports = { draftPrefill, MAX_TITLE, MAX_BODY };@@@ -37,6 +37,7 @@ const forumPages = require('../render/forum');const boardPages = require('../render/board');const { ApiError } = require('../http/v1');const { spacePath, threadPath, legacyForumPath } = require('./paths');++const { draftPrefill } = require('./prefill');function createForumRoutes({ forum, viewers, config }) {const router = express.Router();@@@ -116,12 +117,14 @@ function createForumRoutes({ forum, viewers, config }) {}}));-- // "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.++ // "Discuss in a space" from a paste or another site's share link: pick the space, then its++ // new-topic form with the paste attached or the draft title and body filled in.router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {try {const paste = String(req.query.paste || '').slice(0, 80);++ const { title, body } = draftPrefill(req.query);const { groups } = await forum.listSpaces(req.viewer);-- html(res, boardPages.discussPage({ groups, paste, user: req.user }));++ html(res, boardPages.discussPage({ groups, paste, title, draftBody: body, user: req.user }));} catch (err) { failPage(req, res, err, next); }}));@@@ -185,12 +188,18 @@ function createForumRoutes({ forum, viewers, config }) {router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {try {++ const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';++ const { title, body } = draftPrefill(req.query);++ // A share link fills the form, but only a signed-in person can submit it. Send them to++ // sign in and come back to the same prefilled form (the query is kept in `next`).++ if (!req.user && (paste || title || body)) return login(res, req.originalUrl);// A members-only space: the teaser, not the form, for someone who may not post there.const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const { space } = await forum.space(req.viewer, req.params.space);-- const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';-- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: await forum.pasteTitle(paste) || '' } : {} }));++ const values = { title, body };++ if (paste) { values.pastes = paste; if (!values.title) values.title = await forum.pasteTitle(paste) || ''; }++ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values }));} catch (err) { failPage(req, res, err, next); }}));@@@ -302,13 +302,17 @@ function newSpacePage({ groups = [], error = null, values = {} }) {}/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */--function discussPage({ groups = [], paste = '', user }) {-- const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';++function discussPage({ groups = [], paste = '', title = '', draftBody = '', user }) {++ const params = new URLSearchParams();++ if (paste) params.set('paste', paste);++ if (title) params.set('title', title);++ if (draftBody) params.set('body', draftBody);++ const q = params.size ? `?${params}` : '';const body = `<header class="page-head">${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>-- <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>++ <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : title || draftBody ? 'your draft text' : 'your paste'}${title || draftBody ? ' filled in; edit it before posting.' : ' attached.'}</p>${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}</header>${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;@@@ -8,9 +8,11 @@ const assert = require('assert');const { ids } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');const forumStore = require('../server/forum/store');++const { draftPrefill } = require('../server/forum/prefill');const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });(async () => {@@@ -57,6 +59,81 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringassert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');});++ await check('share drafts travel through the forum picker into the prefilled form, escaped and never in the head', async () => {++ const title = '<script>alert("title")</script> Meme remix draft-9f3a';++ const body = '<script>alert("body")</script>\nMade this with OpenVibe.Meme: https://openvibe.meme/c/mcp_01ARZ3NDEKTSV4RRFFQ69G5FAV draft-9f3a';++ const query = new URLSearchParams({ title, body }).toString();++ const target = `/forum/general/new?${query}`;++ const discuss = await t.get(`/forum/discuss?${query}`);++ assert.strictEqual(discuss.status, 200);++ assert.ok(discuss.text.includes(`href="${target}"`), 'the picker carries the draft into every space');++ assert.ok(discuss.text.includes(`/auth/login?next=${encodeURIComponent(`/forum/discuss?${query}`)}`), 'signing in from the picker keeps the draft query');++ assert.ok(!discuss.text.slice(0, discuss.text.indexOf('</head>')).includes('draft-9f3a'), 'the draft never reaches the picker head');++ has(discuss.text, '<link rel="canonical" href="https://openvibe.community/forum/discuss">');++++ const page = await t.get(target, as(alexJwt));++ assert.strictEqual(page.status, 200);++ has(page.text, `value="${esc(title)}"`, 'the title is escaped in the form value');++ has(page.text, `>${esc(body)}</textarea>`, 'the body is escaped in the textarea');++ assert.ok(!page.text.includes('<script>alert("title")</script>') && !page.text.includes('<script>alert("body")</script>'), 'no raw draft HTML');++ assert.ok(!page.text.slice(0, page.text.indexOf('</head>')).includes('draft-9f3a'), 'the draft never reaches the title, meta tags or canonical');++ has(page.text, '<title>New thread · General · OpenVibe.Community</title>');++ has(page.text, '<link rel="canonical" href="https://openvibe.community/forum/general/new">');++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store', 'a prefilled form is never cached');++ assert.match(page.headers.get('x-robots-tag') || '', /noindex/, 'a prefilled form is never indexed');++++ const picker = await t.get(`/forum/discuss?paste=snip1&${query}`);++ has(picker.text, `/forum/general/new?paste=snip1`, 'a paste and the draft travel together');++ const withPaste = await t.get(`/forum/general/new?paste=snip1&${query}`, as(alexJwt));++ assert.strictEqual(withPaste.status, 200);++ has(withPaste.text, 'name="pastes"', 'the paste stays attached to the draft');++ has(withPaste.text, 'value="snip1"');++ has(withPaste.text, `value="${esc(title)}"`);++ has(withPaste.text, `>${esc(body)}</textarea>`);++ });++++ await check('a share link posts nothing; signed out it signs in and returns to the same prefilled form', async () => {++ const title = 'Draft marker 9f3a', body = 'Body marker 9f3a';++ const query = new URLSearchParams({ title, body }).toString();++ const target = `/forum/general/new?${query}`;++ const space = await forumStore.getSpace(t.db, 'general');++ const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM threads WHERE space_id = ?').get(space.id)).n);++ const before = await count();++++ const anon = await t.get(target);++ assert.strictEqual(anon.status, 303, 'signed out: sent to sign in before the form');++ const location = anon.headers.get('location');++ assert.ok(location.startsWith('/auth/login?next='), location);++ const next = decodeURIComponent(location.slice('/auth/login?next='.length));++ assert.strictEqual(next, target, 'sign-in returns to the same prefilled form');++ assert.ok(next.startsWith('/forum/general/new?'), 'next stays a same-site path');++ const anonPaste = await t.get('/forum/general/new?paste=snip1');++ assert.strictEqual(anonPaste.status, 303, 'a paste prefill signs in too');++ assert.ok(decodeURIComponent(anonPaste.headers.get('location').slice('/auth/login?next='.length)).includes('paste=snip1'));++++ const page = await t.get(target, as(alexJwt));++ assert.strictEqual(page.status, 200);++ has(page.text, 'method="post" action="/forum/general/new"', 'the form is still reviewed and posted by the person');++ assert.strictEqual(await count(), before, 'opening a share link creates no thread');++ assert.ok(!(await t.get('/forum/general')).text.includes(title), 'the draft is not posted into the space');++ });++++ await check('draft limits: title 200, body 40,000, and a long body is carried past the old 6,000 cut', async () => {++ const longTitle = 't'.repeat(250);++ const longBody = 'b'.repeat(10_000);++ const query = new URLSearchParams({ title: longTitle, body: longBody }).toString();++ const page = await t.get(`/forum/general/new?${query}`, as(alexJwt));++ assert.strictEqual(page.status, 200);++ const title = page.text.match(/name="title"[^>]*value="([^"]*)"/)[1];++ assert.strictEqual(title.length, 200, 'the title stops at 200 characters');++ const body = page.text.match(/<textarea name="body"[^>]*>([\s\S]*?)<\/textarea>/)[1];++ assert.strictEqual(body, longBody, 'a 10,000-character body is carried whole');++ const over = draftPrefill({ title: 'x'.repeat(250), body: 'y'.repeat(40_001) });++ assert.strictEqual(over.title.length, 200, 'the cap is 200 characters of title');++ assert.strictEqual(over.body.length, 40_000, 'the cap is 40,000 characters of body');++ assert.deepStrictEqual(draftPrefill({}), { title: '', body: '' });++ });++const url = '/forum/general/how-do-i-b-embed-b-comments';await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {@@@ -17,4 +17,5 @@ for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com'assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));}assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');++assert.strictEqual(sanitizeNext('/forum/general/new?title=Draft&body=Text', config), '/forum/general/new?title=Draft&body=Text', 'a share link keeps its draft query');console.log('open redirect: all checks passed');
@@ -351,6 +351,12 @@ Spaces hold threads; a thread is its opening post plus replies (Markdown). Seede
@@ -351,6 +351,12 @@ Spaces hold threads; a thread is its opening post plus replies (Markdown). Seede
reads, people post), `members` (signed-in people only; every Network account counts as a
reads, people post), `members` (signed-in people only; every Network account counts as a
member for now), `staff` (moderators only; looks missing to everyone else).
member for now), `staff` (moderators only; looks missing to everyone else).
+**Share a draft into the forum.** Another OpenVibe site opens `/forum/discuss?title=…&body=…`
+(optionally with `&paste=<id>`, which attaches a paste) and the picker carries it to
+`/forum/<space>/new` with the fields filled in. Both are plain text, cut to 200 characters of
+title and 40,000 of body and escaped when shown; the link never posts, and a signed-out visitor
+signs in and comes back to the same prefilled form, served `private, no-store` and `noindex`.
+
- **Pages work without JavaScript** — sorting and pagination are links, replying, voting,
- **Pages work without JavaScript** — sorting and pagination are links, replying, voting,
pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` is
pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` is
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+
+/**
+ * A draft another OpenVibe site may carry into the forum through a share link
+ * (/forum/discuss?title=…&body=…). The new-thread form's own limits apply: title 200 characters,
+ * body 40,000. Both are plain text; the renderer escapes them when it fills the form. A link never
+ * posts: the person still reviews and submits the form themselves.
+ */
+const MAX_TITLE = 200;
+const MAX_BODY = 40_000;
+
+/** The first string of a query value (Express hands an array for a repeated parameter). */
+function text(value) {
+ if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || '';
+ return typeof value === 'string' ? value : '';
+}
+
+/** The title and body a share link carries, cut to the form's limits. */
+function draftPrefill(query = {}) {
+ return {
+ title: text(query.title).slice(0, MAX_TITLE),
+ body: text(query.body).slice(0, MAX_BODY),
+ };
+}
+
+module.exports = { draftPrefill, MAX_TITLE, MAX_BODY };
@@ -37,6 +37,7 @@ const forumPages = require('../render/forum');
@@ -37,6 +37,7 @@ const forumPages = require('../render/forum');
const boardPages = require('../render/board');
const boardPages = require('../render/board');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
const { spacePath, threadPath, legacyForumPath } = require('./paths');
const { spacePath, threadPath, legacyForumPath } = require('./paths');
+const { draftPrefill } = require('./prefill');
function createForumRoutes({ forum, viewers, config }) {
function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const router = express.Router();
@@ -116,12 +117,14 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -116,12 +117,14 @@ function createForumRoutes({ forum, viewers, config }) {
}
}
}));
}));
- // "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.
+ // "Discuss in a space" from a paste or another site's share link: pick the space, then its
+ // new-topic form with the paste attached or the draft title and body filled in.
router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {
router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {
try {
try {
const paste = String(req.query.paste || '').slice(0, 80);
const paste = String(req.query.paste || '').slice(0, 80);
+ const { title, body } = draftPrefill(req.query);
const { groups } = await forum.listSpaces(req.viewer);
const { groups } = await forum.listSpaces(req.viewer);
- html(res, boardPages.discussPage({ groups, paste, user: req.user }));
+ html(res, boardPages.discussPage({ groups, paste, title, draftBody: body, user: req.user }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -185,12 +188,18 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -185,12 +188,18 @@ function createForumRoutes({ forum, viewers, config }) {
router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {
router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {
try {
try {
+ const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';
+ const { title, body } = draftPrefill(req.query);
+ // A share link fills the form, but only a signed-in person can submit it. Send them to
+ // sign in and come back to the same prefilled form (the query is kept in `next`).
+ if (!req.user && (paste || title || body)) return login(res, req.originalUrl);
// A members-only space: the teaser, not the form, for someone who may not post there.
// A members-only space: the teaser, not the form, for someone who may not post there.
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
- const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';
- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: await forum.pasteTitle(paste) || '' } : {} }));
+ const values = { title, body };
+ if (paste) { values.pastes = paste; if (!values.title) values.title = await forum.pasteTitle(paste) || ''; }
+ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -302,13 +302,17 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
@@ -302,13 +302,17 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
}
}
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
-function discussPage({ groups = [], paste = '', user }) {
- const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
+function discussPage({ groups = [], paste = '', title = '', draftBody = '', user }) {
+ const params = new URLSearchParams();
+ if (paste) params.set('paste', paste);
+ if (title) params.set('title', title);
+ if (draftBody) params.set('body', draftBody);
+ const q = params.size ? `?${params}` : '';
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}
${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}
<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
- <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
+ <p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : title || draftBody ? 'your draft text' : 'your paste'}${title || draftBody ? ' filled in; edit it before posting.' : ' attached.'}</p>
${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
</header>
</header>
${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
@@ -8,9 +8,11 @@ const assert = require('assert');
@@ -8,9 +8,11 @@ const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const forumStore = require('../server/forum/store');
const forumStore = require('../server/forum/store');
+const { draftPrefill } = require('../server/forum/prefill');
const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
const ld = (html) => (html.match(/<script type="application\/ld\+json">(.*?)<\/script>/g) || []).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
(async () => {
(async () => {
@@ -57,6 +59,81 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -57,6 +59,81 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
});
});
+ await check('share drafts travel through the forum picker into the prefilled form, escaped and never in the head', async () => {
+ const title = '<script>alert("title")</script> Meme remix draft-9f3a';
+ const body = '<script>alert("body")</script>\nMade this with OpenVibe.Meme: https://openvibe.meme/c/mcp_01ARZ3NDEKTSV4RRFFQ69G5FAV draft-9f3a';
+ const query = new URLSearchParams({ title, body }).toString();
+ const target = `/forum/general/new?${query}`;
+ const discuss = await t.get(`/forum/discuss?${query}`);
+ assert.strictEqual(discuss.status, 200);
+ assert.ok(discuss.text.includes(`href="${target}"`), 'the picker carries the draft into every space');
+ assert.ok(discuss.text.includes(`/auth/login?next=${encodeURIComponent(`/forum/discuss?${query}`)}`), 'signing in from the picker keeps the draft query');
+ assert.ok(!discuss.text.slice(0, discuss.text.indexOf('</head>')).includes('draft-9f3a'), 'the draft never reaches the picker head');
+ has(discuss.text, '<link rel="canonical" href="https://openvibe.community/forum/discuss">');
+
+ const page = await t.get(target, as(alexJwt));
+ assert.strictEqual(page.status, 200);
+ has(page.text, `value="${esc(title)}"`, 'the title is escaped in the form value');
+ has(page.text, `>${esc(body)}</textarea>`, 'the body is escaped in the textarea');
+ assert.ok(!page.text.includes('<script>alert("title")</script>') && !page.text.includes('<script>alert("body")</script>'), 'no raw draft HTML');
+ assert.ok(!page.text.slice(0, page.text.indexOf('</head>')).includes('draft-9f3a'), 'the draft never reaches the title, meta tags or canonical');
+ has(page.text, '<title>New thread · General · OpenVibe.Community</title>');
+ has(page.text, '<link rel="canonical" href="https://openvibe.community/forum/general/new">');
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store', 'a prefilled form is never cached');
+ assert.match(page.headers.get('x-robots-tag') || '', /noindex/, 'a prefilled form is never indexed');
+
+ const picker = await t.get(`/forum/discuss?paste=snip1&${query}`);
+ has(picker.text, `/forum/general/new?paste=snip1`, 'a paste and the draft travel together');
+ const withPaste = await t.get(`/forum/general/new?paste=snip1&${query}`, as(alexJwt));
+ assert.strictEqual(withPaste.status, 200);
+ has(withPaste.text, 'name="pastes"', 'the paste stays attached to the draft');
+ has(withPaste.text, 'value="snip1"');
+ has(withPaste.text, `value="${esc(title)}"`);
+ has(withPaste.text, `>${esc(body)}</textarea>`);
+ });
+
+ await check('a share link posts nothing; signed out it signs in and returns to the same prefilled form', async () => {
+ const title = 'Draft marker 9f3a', body = 'Body marker 9f3a';
+ const query = new URLSearchParams({ title, body }).toString();
+ const target = `/forum/general/new?${query}`;
+ const space = await forumStore.getSpace(t.db, 'general');
+ const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM threads WHERE space_id = ?').get(space.id)).n);
+ const before = await count();
+
+ const anon = await t.get(target);
+ assert.strictEqual(anon.status, 303, 'signed out: sent to sign in before the form');
+ const location = anon.headers.get('location');
+ assert.ok(location.startsWith('/auth/login?next='), location);
+ const next = decodeURIComponent(location.slice('/auth/login?next='.length));
+ assert.strictEqual(next, target, 'sign-in returns to the same prefilled form');
+ assert.ok(next.startsWith('/forum/general/new?'), 'next stays a same-site path');
+ const anonPaste = await t.get('/forum/general/new?paste=snip1');
+ assert.strictEqual(anonPaste.status, 303, 'a paste prefill signs in too');
+ assert.ok(decodeURIComponent(anonPaste.headers.get('location').slice('/auth/login?next='.length)).includes('paste=snip1'));
+
+ const page = await t.get(target, as(alexJwt));
+ assert.strictEqual(page.status, 200);
+ has(page.text, 'method="post" action="/forum/general/new"', 'the form is still reviewed and posted by the person');
+ assert.strictEqual(await count(), before, 'opening a share link creates no thread');
+ assert.ok(!(await t.get('/forum/general')).text.includes(title), 'the draft is not posted into the space');
+ });
+
+ await check('draft limits: title 200, body 40,000, and a long body is carried past the old 6,000 cut', async () => {
+ const longTitle = 't'.repeat(250);
+ const longBody = 'b'.repeat(10_000);
+ const query = new URLSearchParams({ title: longTitle, body: longBody }).toString();
+ const page = await t.get(`/forum/general/new?${query}`, as(alexJwt));
+ assert.strictEqual(page.status, 200);
+ const title = page.text.match(/name="title"[^>]*value="([^"]*)"/)[1];
+ assert.strictEqual(title.length, 200, 'the title stops at 200 characters');
+ const body = page.text.match(/<textarea name="body"[^>]*>([\s\S]*?)<\/textarea>/)[1];
+ assert.strictEqual(body, longBody, 'a 10,000-character body is carried whole');
+ const over = draftPrefill({ title: 'x'.repeat(250), body: 'y'.repeat(40_001) });
+ assert.strictEqual(over.title.length, 200, 'the cap is 200 characters of title');
+ assert.strictEqual(over.body.length, 40_000, 'the cap is 40,000 characters of body');
+ assert.deepStrictEqual(draftPrefill({}), { title: '', body: '' });
+ });
+
const url = '/forum/general/how-do-i-b-embed-b-comments';
const url = '/forum/general/how-do-i-b-embed-b-comments';
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
@@ -17,4 +17,5 @@ for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com'
@@ -17,4 +17,5 @@ for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com'
assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
}
}
assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
+assert.strictEqual(sanitizeNext('/forum/general/new?title=Draft&body=Text', config), '/forum/general/new?title=Draft&body=Text', 'a share link keeps its draft query');
console.log('open redirect: all checks passed');
console.log('open redirect: all checks passed');