openvibers/openvibe.community Commits
Every forum opens with a pinned guide by OpenVibe (welcome, how to get help, how feedback works, show what you made, off-topic rules) from docs/forum/starters.json, synced at boot like the Roadmap; five of six forums had no post at all (#102)
Changes
@@@ -378,6 +378,9 @@ member for now), `staff` (moderators only; looks missing to everyone else).only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summaryis an edit of the item's opening post, so the history stays, and replies and votes stay with the item.++- **Starter threads.** Every forum but the Roadmap opens with a pinned guide by OpenVibe (welcome, how to get++ help, how feedback works, showcase, off-topic rules) from [`docs/forum/starters.json`](docs/forum/starters.json),++ synced at boot by `server/forum/starters.js` the same way. Staff unpinning, locking or deleting one sticks.- **Images on posts.** Up to 4 per thread or reply: PNG, JPEG, GIF or WebP by content, 8 MB each, withmetadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by thennew file mode 100644@@@ -0,0 +1,35 @@++{++ "about": "Pinned starter threads, one per forum, written by the site itself (shown as OpenVibe). server/forum/starters.js syncs this file at every boot: a new key becomes a pinned thread, a changed title or body updates it in place.",++ "items": [++ {++ "key": "welcome",++ "space": "general",++ "title": "Welcome to OpenVibe: start here",++ "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/s/feedback), where people vote on them. What we are building next is on the [Roadmap](/s/roadmap). Everything here follows the [terms](/terms)."++ },++ {++ "key": "how-to-get-help",++ "space": "help",++ "title": "How to get help (read this first)",++ "body": "Ask anything here: streaming, tools, your account, any OpenVibe site. Other people and staff answer in the thread, so the next person with the same question finds it.\n\nA question gets answered faster when it says:\n\n- **Which site and page**, with its link.\n- **What you did**, step by step.\n- **What you expected, and what happened instead.** Copy any error message exactly.\n- **Your device and browser**, for example \"Android, Chrome\" or \"Windows, Firefox\".\n\nNever post a password, a stream key, an API token or a sign-in link. Nobody from OpenVibe will ask you for one.\n\nOther places to look:\n\n- [OpenVibe.Help](https://openvibe.help) searches every site's answers, and you can open a private ticket there for account or payment problems.\n- [Network status](https://openvibe.network/status) shows whether a site is having trouble right now.\n- Going live: [the guide to streaming from your browser](https://openvibe.live/docs/go-live-in-your-browser).\n\nWhen your question is answered, say what fixed it. It helps the next person."++ },++ {++ "key": "how-feedback-works",++ "space": "feedback",++ "title": "How feedback works",++ "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/s/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."++ },++ {++ "key": "show-what-you-made",++ "space": "showcase",++ "title": "Show what you made",++ "body": "This forum is for your work: streams, clips, art, music, code, tools, game worlds and sites. Things made with OpenVibe and things made elsewhere are both welcome.\n\nA good showcase post:\n\n- **Links to it**: your channel on [OpenVibe.Live](https://openvibe.live), a world on [OpenVibe.Games](https://openvibe.games/worlds), a site you published on [OpenVibe.Space](https://openvibe.space), or anywhere else.\n- **Says what it is and how you made it.** People like to know the tools and the story.\n- **Credits the people and sources you used.**\n\nPosts are ranked by votes. Only post work that is yours or that you have permission to share."++ },++ {++ "key": "off-topic-house-rules",++ "space": "off-topic",++ "title": "Off-topic: house rules",++ "body": "Anything that is not about OpenVibe goes here: games, music, what you are watching, your day.\n\nThe same few rules apply as everywhere on OpenVibe:\n\n- Be kind. Argue with ideas, not with people.\n- No spam, ads or link farming.\n- No threats, and nobody's private information.\n- Nothing illegal, nothing you do not have the rights to, and mark adult content as adult.\n\nIf a post breaks them, tell staff with a private ticket at [OpenVibe.Help](https://openvibe.help). The full rules are in the [terms](/terms)."++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++++/**++ * Pinned starter threads (docs/forum/starters.json): one guide per forum, written by the site itself (origin++ * 'system', shown as OpenVibe), so a forum with no posts yet says what belongs in it and how to post. Every boot++ * syncs the file like the Roadmap: a new item becomes a pinned thread in its space; a changed title or body updates++ * the thread (the body as an edit of its opening post, so the history stays); an item that left the file keeps its++ * thread. Items are matched by key (threads.external_key). Staff own the thread once it exists: unpinning, locking++ * or deleting it sticks.++ */++const fs = require('fs');++const path = require('path');++const store = require('./store');++++const FILE = path.join(__dirname, '..', '..', 'docs', 'forum', 'starters.json');++const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;++++/** items: [{ key, space, title, body }] → { created, updated, unchanged, skipped } */++async function syncStarters(db, items, { log = console } = {}) {++ const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };++ for (const item of Array.isArray(items) ? items : []) {++ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();++ const body = String((item && item.body) || '').trim();++ const space = item && typeof item.space === 'string' ? await store.getSpace(db, item.space) : null;++ if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !body || !space) {++ out.skipped++;++ log.warn && log.warn(`[Starters] skipped an item that is not { key, space, title, body } in a known space: ${JSON.stringify(item && item.key)}`);++ continue;++ }++ const key = `starter:${item.key}`;++ const existing = await store.getThreadByKey(db, space.id, key);++ if (!existing) {++ const { thread } = await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: body, external_key: key });++ await store.setThreadFlags(db, thread.id, { pinned: true });++ out.created++;++ continue;++ }++ if (existing.deleted_at) { out.unchanged++; continue; }++ let changed = false;++ if (existing.title !== title) {++ await db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, existing.id);++ changed = true;++ }++ const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);++ if (opening && opening.body_markdown !== body) { await store.editPost(db, opening.id, body, 'system'); changed = true; }++ if (changed) out.updated++; else out.unchanged++;++ }++ return out;++}++++/** Sync from docs/forum/starters.json (or `file`). A missing or broken file changes nothing. */++async function syncFromFile(db, { file = FILE, log = console } = {}) {++ let doc;++ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Starters] ${file}: ${err.message}`); return null; }++ return await syncStarters(db, doc.items, { log });++}++++module.exports = { syncStarters, syncFromFile, FILE };@@@ -25,6 +25,11 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkconst r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);} catch (err) { console.warn('[Roadmap] not synced:', err.message); }++ // Each forum's pinned starter thread follows docs/forum/starters.json (server/forum/starters.js).++ try {++ const r = await require('./forum/starters').syncFromFile(require('./db').getDb());++ if (r && (r.created || r.updated)) console.log(`[Starters] ${r.created} thread(s) added, ${r.updated} updated`);++ } catch (err) { console.warn('[Starters] not synced:', err.message); }const server = app.listen(config.port, config.host, () => {console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * Pinned starter threads (server/forum/starters.js, docs/forum/starters.json): the shipped file gives every forum but++ * the Roadmap one pinned thread by OpenVibe that leads its list; a second sync changes nothing; a changed body is an++ * edit (history kept); staff unpinning or deleting a starter sticks; bad items and unknown spaces are skipped; the++ * bodies link only to OpenVibe sites and same-site paths.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters');++++(async () => {++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });++ const call = (path, { method = 'GET', cookie, json } = {}) => {++ const h = {};++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++ const quiet = { warn() {} };++ const shipped = require(FILE).items;++++ await check('the shipped file: one pinned starter by OpenVibe in each forum but the Roadmap, leading its list', async () => {++ assert.deepStrictEqual(shipped.map((i) => i.space).sort(), ['feedback', 'general', 'help', 'off-topic', 'showcase']);++ // Someone posted first: the starter still leads.++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Hello everyone', body: 'Hi!' } })).status, 201);++ assert.deepStrictEqual(await syncFromFile(t.db, { log: quiet }), { created: 5, updated: 0, unchanged: 0, skipped: 0 });++ for (const item of shipped) {++ const list = (await call(`/api/v1/spaces/${item.space}/threads?sort=new`)).json().threads;++ assert.strictEqual(list[0].title, item.title, item.space);++ assert.ok(list[0].pinned, item.space);++ assert.strictEqual(list[0].origin, 'system');++ assert.strictEqual(list[0].author.display_name, 'OpenVibe');++ }++ assert.deepStrictEqual(await syncFromFile(t.db, { log: quiet }), { created: 0, updated: 0, unchanged: 5, skipped: 0 }, 'a fixed point');++ });++++ await check('the page renders the guide with its links (no raw HTML)', async () => {++ const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');++ const r = await call(`/s/feedback/t/${th.slug}`);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('href="/s/roadmap"'), 'same-site link');++ assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));++ });++++ await check('bodies link only to OpenVibe sites and same-site paths, and never ask for secrets', async () => {++ for (const item of shipped) {++ for (const [, url] of item.body.matchAll(/\]\(([^)]+)\)/g)) {++ assert.ok(/^\/[a-z]/.test(url) || /^https:\/\/openvibe\.(network|live|tools|games|space|help|community|media|chat)(\/|$)/.test(url), `${item.key}: ${url}`);++ }++ }++ assert.match(shipped.find((i) => i.space === 'help').body, /Never post a password, a stream key, an API token/);++ });++++ await check('an edited body is an edit of the opening post; staff unpinning or deleting a starter sticks', async () => {++ const edited = shipped.map((i) => (i.key === 'how-to-get-help' ? { ...i, body: `${i.body}\n\nUpdated.` } : i));++ assert.deepStrictEqual(await syncStarters(t.db, edited, { log: quiet }), { created: 0, updated: 1, unchanged: 4, skipped: 0 });++ const help = (await call('/api/v1/spaces/help/threads')).json().threads[0];++ const page = (await call(`/api/v1/spaces/help/threads/${help.slug}`)).json();++ assert.ok(page.posts[0].body_markdown.endsWith('Updated.'));++ assert.strictEqual(page.posts[0].revision, 2, 'the history stays');++++ const welcome = (await call('/api/v1/spaces/general/threads')).json().threads.find((x) => x.title === 'Welcome to OpenVibe: start here');++ const r = await call(`/api/v1/spaces/general/threads/${welcome.slug}/state`, { method: 'PUT', cookie: modJwt, json: { pinned: false } });++ assert.ok(r.status < 300, `unpin answered ${r.status}`);++ await syncStarters(t.db, shipped, { log: quiet });++ const after = (await call(`/api/v1/spaces/general/threads/${welcome.slug}`)).json();++ assert.ok(!after.thread.pinned, 'a sync never re-pins');++++ await t.db.prepare("UPDATE threads SET deleted_at = now() WHERE id = ?").run(welcome.id);++ const counts = await syncStarters(t.db, shipped, { log: quiet });++ assert.strictEqual(counts.created, 0, 'a deleted starter is not made again');++ });++++ await check('bad items and unknown spaces are skipped', async () => {++ const bad = [{ key: 'Bad Key', space: 'general', title: 'Title', body: 'x' }, { key: 'nowhere', space: 'no-such-space', title: 'Title', body: 'x' }, { key: 'nobody', space: 'general', title: 'Title' }];++ assert.deepStrictEqual(await syncStarters(t.db, bad, { log: quiet }), { created: 0, updated: 0, unchanged: 0, skipped: 3 });++ });++++ await done(t);++})();
@@ -378,6 +378,9 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -378,6 +378,9 @@ member for now), `staff` (moderators only; looks missing to everyone else).
only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):
only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):
every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary
every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary
is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.
is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.
+- **Starter threads.** Every forum but the Roadmap opens with a pinned guide by OpenVibe (welcome, how to get
+ help, how feedback works, showcase, off-topic rules) from [`docs/forum/starters.json`](docs/forum/starters.json),
+ synced at boot by `server/forum/starters.js` the same way. Staff unpinning, locking or deleting one sticks.
- **Images on posts.** Up to 4 per thread or reply: PNG, JPEG, GIF or WebP by content, 8 MB each, with
- **Images on posts.** Up to 4 per thread or reply: PNG, JPEG, GIF or WebP by content, 8 MB each, with
metadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by the
metadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by the
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+{
+ "about": "Pinned starter threads, one per forum, written by the site itself (shown as OpenVibe). server/forum/starters.js syncs this file at every boot: a new key becomes a pinned thread, a changed title or body updates it in place.",
+ "items": [
+ {
+ "key": "welcome",
+ "space": "general",
+ "title": "Welcome to OpenVibe: start here",
+ "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/s/feedback), where people vote on them. What we are building next is on the [Roadmap](/s/roadmap). Everything here follows the [terms](/terms)."
+ },
+ {
+ "key": "how-to-get-help",
+ "space": "help",
+ "title": "How to get help (read this first)",
+ "body": "Ask anything here: streaming, tools, your account, any OpenVibe site. Other people and staff answer in the thread, so the next person with the same question finds it.\n\nA question gets answered faster when it says:\n\n- **Which site and page**, with its link.\n- **What you did**, step by step.\n- **What you expected, and what happened instead.** Copy any error message exactly.\n- **Your device and browser**, for example \"Android, Chrome\" or \"Windows, Firefox\".\n\nNever post a password, a stream key, an API token or a sign-in link. Nobody from OpenVibe will ask you for one.\n\nOther places to look:\n\n- [OpenVibe.Help](https://openvibe.help) searches every site's answers, and you can open a private ticket there for account or payment problems.\n- [Network status](https://openvibe.network/status) shows whether a site is having trouble right now.\n- Going live: [the guide to streaming from your browser](https://openvibe.live/docs/go-live-in-your-browser).\n\nWhen your question is answered, say what fixed it. It helps the next person."
+ },
+ {
+ "key": "how-feedback-works",
+ "space": "feedback",
+ "title": "How feedback works",
+ "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/s/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."
+ },
+ {
+ "key": "show-what-you-made",
+ "space": "showcase",
+ "title": "Show what you made",
+ "body": "This forum is for your work: streams, clips, art, music, code, tools, game worlds and sites. Things made with OpenVibe and things made elsewhere are both welcome.\n\nA good showcase post:\n\n- **Links to it**: your channel on [OpenVibe.Live](https://openvibe.live), a world on [OpenVibe.Games](https://openvibe.games/worlds), a site you published on [OpenVibe.Space](https://openvibe.space), or anywhere else.\n- **Says what it is and how you made it.** People like to know the tools and the story.\n- **Credits the people and sources you used.**\n\nPosts are ranked by votes. Only post work that is yours or that you have permission to share."
+ },
+ {
+ "key": "off-topic-house-rules",
+ "space": "off-topic",
+ "title": "Off-topic: house rules",
+ "body": "Anything that is not about OpenVibe goes here: games, music, what you are watching, your day.\n\nThe same few rules apply as everywhere on OpenVibe:\n\n- Be kind. Argue with ideas, not with people.\n- No spam, ads or link farming.\n- No threats, and nobody's private information.\n- Nothing illegal, nothing you do not have the rights to, and mark adult content as adult.\n\nIf a post breaks them, tell staff with a private ticket at [OpenVibe.Help](https://openvibe.help). The full rules are in the [terms](/terms)."
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+
+/**
+ * Pinned starter threads (docs/forum/starters.json): one guide per forum, written by the site itself (origin
+ * 'system', shown as OpenVibe), so a forum with no posts yet says what belongs in it and how to post. Every boot
+ * syncs the file like the Roadmap: a new item becomes a pinned thread in its space; a changed title or body updates
+ * the thread (the body as an edit of its opening post, so the history stays); an item that left the file keeps its
+ * thread. Items are matched by key (threads.external_key). Staff own the thread once it exists: unpinning, locking
+ * or deleting it sticks.
+ */
+const fs = require('fs');
+const path = require('path');
+const store = require('./store');
+
+const FILE = path.join(__dirname, '..', '..', 'docs', 'forum', 'starters.json');
+const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;
+
+/** items: [{ key, space, title, body }] → { created, updated, unchanged, skipped } */
+async function syncStarters(db, items, { log = console } = {}) {
+ const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
+ for (const item of Array.isArray(items) ? items : []) {
+ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
+ const body = String((item && item.body) || '').trim();
+ const space = item && typeof item.space === 'string' ? await store.getSpace(db, item.space) : null;
+ if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !body || !space) {
+ out.skipped++;
+ log.warn && log.warn(`[Starters] skipped an item that is not { key, space, title, body } in a known space: ${JSON.stringify(item && item.key)}`);
+ continue;
+ }
+ const key = `starter:${item.key}`;
+ const existing = await store.getThreadByKey(db, space.id, key);
+ if (!existing) {
+ const { thread } = await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: body, external_key: key });
+ await store.setThreadFlags(db, thread.id, { pinned: true });
+ out.created++;
+ continue;
+ }
+ if (existing.deleted_at) { out.unchanged++; continue; }
+ let changed = false;
+ if (existing.title !== title) {
+ await db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, existing.id);
+ changed = true;
+ }
+ const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);
+ if (opening && opening.body_markdown !== body) { await store.editPost(db, opening.id, body, 'system'); changed = true; }
+ if (changed) out.updated++; else out.unchanged++;
+ }
+ return out;
+}
+
+/** Sync from docs/forum/starters.json (or `file`). A missing or broken file changes nothing. */
+async function syncFromFile(db, { file = FILE, log = console } = {}) {
+ let doc;
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Starters] ${file}: ${err.message}`); return null; }
+ return await syncStarters(db, doc.items, { log });
+}
+
+module.exports = { syncStarters, syncFromFile, FILE };
@@ -25,6 +25,11 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -25,6 +25,11 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());
const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());
if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
} catch (err) { console.warn('[Roadmap] not synced:', err.message); }
} catch (err) { console.warn('[Roadmap] not synced:', err.message); }
+ // Each forum's pinned starter thread follows docs/forum/starters.json (server/forum/starters.js).
+ try {
+ const r = await require('./forum/starters').syncFromFile(require('./db').getDb());
+ if (r && (r.created || r.updated)) console.log(`[Starters] ${r.created} thread(s) added, ${r.updated} updated`);
+ } catch (err) { console.warn('[Starters] not synced:', err.message); }
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
console.log(`[Community] pastes: this site is the authority (PostgreSQL), identity via ${config.networkUrl}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * Pinned starter threads (server/forum/starters.js, docs/forum/starters.json): the shipped file gives every forum but
+ * the Roadmap one pinned thread by OpenVibe that leads its list; a second sync changes nothing; a changed body is an
+ * edit (history kept); staff unpinning or deleting a starter sticks; bad items and unknown spaces are skipped; the
+ * bodies link only to OpenVibe sites and same-site paths.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters');
+
+(async () => {
+ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
+ const call = (path, { method = 'GET', cookie, json } = {}) => {
+ const h = {};
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+ const quiet = { warn() {} };
+ const shipped = require(FILE).items;
+
+ await check('the shipped file: one pinned starter by OpenVibe in each forum but the Roadmap, leading its list', async () => {
+ assert.deepStrictEqual(shipped.map((i) => i.space).sort(), ['feedback', 'general', 'help', 'off-topic', 'showcase']);
+ // Someone posted first: the starter still leads.
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Hello everyone', body: 'Hi!' } })).status, 201);
+ assert.deepStrictEqual(await syncFromFile(t.db, { log: quiet }), { created: 5, updated: 0, unchanged: 0, skipped: 0 });
+ for (const item of shipped) {
+ const list = (await call(`/api/v1/spaces/${item.space}/threads?sort=new`)).json().threads;
+ assert.strictEqual(list[0].title, item.title, item.space);
+ assert.ok(list[0].pinned, item.space);
+ assert.strictEqual(list[0].origin, 'system');
+ assert.strictEqual(list[0].author.display_name, 'OpenVibe');
+ }
+ assert.deepStrictEqual(await syncFromFile(t.db, { log: quiet }), { created: 0, updated: 0, unchanged: 5, skipped: 0 }, 'a fixed point');
+ });
+
+ await check('the page renders the guide with its links (no raw HTML)', async () => {
+ const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');
+ const r = await call(`/s/feedback/t/${th.slug}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('href="/s/roadmap"'), 'same-site link');
+ assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));
+ });
+
+ await check('bodies link only to OpenVibe sites and same-site paths, and never ask for secrets', async () => {
+ for (const item of shipped) {
+ for (const [, url] of item.body.matchAll(/\]\(([^)]+)\)/g)) {
+ assert.ok(/^\/[a-z]/.test(url) || /^https:\/\/openvibe\.(network|live|tools|games|space|help|community|media|chat)(\/|$)/.test(url), `${item.key}: ${url}`);
+ }
+ }
+ assert.match(shipped.find((i) => i.space === 'help').body, /Never post a password, a stream key, an API token/);
+ });
+
+ await check('an edited body is an edit of the opening post; staff unpinning or deleting a starter sticks', async () => {
+ const edited = shipped.map((i) => (i.key === 'how-to-get-help' ? { ...i, body: `${i.body}\n\nUpdated.` } : i));
+ assert.deepStrictEqual(await syncStarters(t.db, edited, { log: quiet }), { created: 0, updated: 1, unchanged: 4, skipped: 0 });
+ const help = (await call('/api/v1/spaces/help/threads')).json().threads[0];
+ const page = (await call(`/api/v1/spaces/help/threads/${help.slug}`)).json();
+ assert.ok(page.posts[0].body_markdown.endsWith('Updated.'));
+ assert.strictEqual(page.posts[0].revision, 2, 'the history stays');
+
+ const welcome = (await call('/api/v1/spaces/general/threads')).json().threads.find((x) => x.title === 'Welcome to OpenVibe: start here');
+ const r = await call(`/api/v1/spaces/general/threads/${welcome.slug}/state`, { method: 'PUT', cookie: modJwt, json: { pinned: false } });
+ assert.ok(r.status < 300, `unpin answered ${r.status}`);
+ await syncStarters(t.db, shipped, { log: quiet });
+ const after = (await call(`/api/v1/spaces/general/threads/${welcome.slug}`)).json();
+ assert.ok(!after.thread.pinned, 'a sync never re-pins');
+
+ await t.db.prepare("UPDATE threads SET deleted_at = now() WHERE id = ?").run(welcome.id);
+ const counts = await syncStarters(t.db, shipped, { log: quiet });
+ assert.strictEqual(counts.created, 0, 'a deleted starter is not made again');
+ });
+
+ await check('bad items and unknown spaces are skipped', async () => {
+ const bad = [{ key: 'Bad Key', space: 'general', title: 'Title', body: 'x' }, { key: 'nowhere', space: 'no-such-space', title: 'Title', body: 'x' }, { key: 'nobody', space: 'general', title: 'Title' }];
+ assert.deepStrictEqual(await syncStarters(t.db, bad, { log: quiet }), { created: 0, updated: 0, unchanged: 0, skipped: 3 });
+ });
+
+ await done(t);
+})();