openvibers/openvibe.community Commits

Carry share-link drafts into the forum's new-thread form (#333)

OP OpenVibers committed 3 days ago · 8b638187c4 · 6 files, +130 −7

A share link (/forum/discuss?title=&body=, optionally with paste) now carries the draft through the forum picker into /forum/<space>/new, cut to the form's 200/40,000 limits and escaped as plain text. A signed-out visitor is sent to sign in with the same prefilled URL in next, and a link never posts. Prefilled forms answer private, no-store and noindex, and the draft never reaches the title, meta tags or canonical. Adds the share-link shape to the README and tests for the flow, limits, escaping, sign-in return and open-redirect handling.

Changes

ddiff --git a/README.md b/README.md
iindex e0b5778..a1d32e1 100644
---- a/README.md
++++ b/README.md
@@@ -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
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,
pin/lock and delete are plain form posts (SameSite=Lax cookie; a foreign `Origin` is
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
ddiff --git a/server/forum/prefill.js b/server/forum/prefill.js
nnew file mode 100644
iindex 0000000..b623b6c
---- /dev/null
++++ b/server/forum/prefill.js
@@@ -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 };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex e64e629..f4653f4 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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); }
}));
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 3e03a75..8cd8432 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -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('')}`;
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex 7aee59d..d7727fa 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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).toString
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';
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
iindex 5a267e1..7c3a250 100644
---- a/test/open-redirect.test.js
++++ b/test/open-redirect.test.js
@@@ -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');