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)

OP OpenVibers committed 25 days ago · 73f23f11a6 · 5 files, +187 −0

Changes

ddiff --git a/README.md b/README.md
iindex 88b16f2..7137034 100644
---- a/README.md
++++ b/README.md
@@@ -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 summary
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
metadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by the
ddiff --git a/docs/forum/starters.json b/docs/forum/starters.json
nnew file mode 100644
iindex 0000000..00fbcf3
---- /dev/null
++++ b/docs/forum/starters.json
@@@ -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)."
++ }
++ ]
++}
ddiff --git a/server/forum/starters.js b/server/forum/starters.js
nnew file mode 100644
iindex 0000000..2ff9f9b
---- /dev/null
++++ b/server/forum/starters.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 5ac008b..42909bf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -25,6 +25,11 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
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`);
} 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}`);
ddiff --git a/test/forum-starters.test.js b/test/forum-starters.test.js
nnew file mode 100644
iindex 0000000..0fd68c2
---- /dev/null
++++ b/test/forum-starters.test.js
@@@ -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);
++})();