openvibers/openvibe.chat Commits

Add Community backed forum channels (#458)

OP OpenVibers committed 4 days ago · 0bac9357a2 · 12 files, +268 −16

* Add Community backed forum channels

* Document forum owner setting

* Merge remote-tracking branch 'origin/main' into chat/v3-forum-channels

* Move forum migration after server emoji migration

* Merge stage channels into forum channels branch

* Review Community forum outbound client

Changes

ddiff --git a/migrations/0059_forum_channels.sql b/migrations/0059_forum_channels.sql
nnew file mode 100644
iindex 0000000..3982d07
---- /dev/null
++++ b/migrations/0059_forum_channels.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- A Chat forum channel is a view of a Community forum space; Community remains the thread store.
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS forum_space text COLLATE "C";
++CREATE INDEX IF NOT EXISTS idx_rooms_forum_space ON rooms (forum_space) WHERE forum_space IS NOT NULL AND archived_at IS NULL;
ddiff --git a/server/config.js b/server/config.js
iindex 283bf5a..068ec4e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -234,6 +234,11 @@ module.exports = {
scope: 'bench.round.create bench.judgement.cast',
},
exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },
++ community: {
++ internalUrl: strip(process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),
++ publicUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
++ timeoutMs: Math.max(1000, int(process.env.CHAT_COMMUNITY_TIMEOUT_MS, 5000)),
++ },
// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
ddiff --git a/server/rooms/community-forum.js b/server/rooms/community-forum.js
nnew file mode 100644
iindex 0000000..c21d04a
---- /dev/null
++++ b/server/rooms/community-forum.js
@@@ -0,0 +1,80 @@
++'use strict';
++
++/** Forum channels are views over Community's forum API. Chat never stores forum posts or replies. */
++const config = require('../config');
++
++class ForumError extends Error {
++ constructor(status, message, code = 'forum.unavailable') { super(message); this.status = status; this.code = code; }
++}
++const slugOf = (v) => {
++ const s = String(v || '').trim().toLowerCase();
++ return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(s) ? s : null;
++};
++
++async function call(method, path, { token = null, body = null } = {}) {
++ const headers = { Accept: 'application/json' };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ if (body !== null) headers['Content-Type'] = 'application/json';
++ let response;
++ try {
++ response = await fetch(`${config.community.internalUrl}/api/v1/spaces${path}`, {
++ method, headers, ...(body !== null ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(config.community.timeoutMs),
++ });
++ } catch {
++ throw new ForumError(503, 'Community forums are unavailable right now');
++ }
++ const data = await response.json().catch(() => null);
++ if (!response.ok) throw new ForumError(response.status, data && (data.detail || data.error) || 'Community refused the forum request', data && data.code || 'forum.community_error');
++ return data;
++}
++
++async function verifyManage(space, token) {
++ const slug = slugOf(space);
++ if (!slug) throw new ForumError(422, 'Choose a valid Community forum address', 'forum.space_invalid');
++ if (!token) throw new ForumError(401, 'Sign in again before connecting a forum', 'forum.sign_in');
++ const out = await call('GET', `/${encodeURIComponent(slug)}/threads?limit=1`, { token });
++ if (!out || !out.space || out.space.style !== 'forum') throw new ForumError(422, 'That Community space is not a forum', 'forum.not_a_forum');
++ if (!out.viewer || !out.viewer.can_moderate) throw new ForumError(403, 'You must moderate that Community forum to connect it', 'forum.not_a_moderator');
++ return { slug, space: out.space };
++}
++
++async function list(space, token, { sort = 'active', category = '', page = 1, limit = 30 } = {}) {
++ const slug = slugOf(space);
++ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');
++ const q = new URLSearchParams();
++ q.set('sort', ['active', 'new'].includes(sort) ? sort : 'active');
++ q.set('page', String(Math.max(1, Math.min(1000, Number(page) || 1))));
++ q.set('limit', String(Math.max(1, Math.min(50, Number(limit) || 30))));
++ if (category) q.set('category', String(category).slice(0, 50));
++ return await call('GET', `/${encodeURIComponent(slug)}/threads?${q}`, { token });
++}
++
++async function thread(space, slug, token, { page = 1 } = {}) {
++ const s = slugOf(space);
++ const t = slugOf(slug);
++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
++ return await call('GET', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}?page=${Math.max(1, Number(page) || 1)}`, { token });
++}
++
++async function create(space, token, body) {
++ const slug = slugOf(space);
++ if (!slug) throw new ForumError(404, 'No such forum', 'forum.not_found');
++ return await call('POST', `/${encodeURIComponent(slug)}/threads`, { token, body });
++}
++async function reply(space, threadSlug, token, body) {
++ const s = slugOf(space), t = slugOf(threadSlug);
++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
++ return await call('POST', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/posts`, { token, body });
++}
++async function moderate(space, threadSlug, token, body) {
++ const s = slugOf(space), t = slugOf(threadSlug);
++ if (!s || !t) throw new ForumError(404, 'No such forum post', 'forum.not_found');
++ return await call('PUT', `/${encodeURIComponent(s)}/threads/${encodeURIComponent(t)}/state`, { token, body });
++}
++async function categories(space, token) {
++ const s = slugOf(space);
++ if (!s) throw new ForumError(404, 'No such forum', 'forum.not_found');
++ return await call('GET', `/${encodeURIComponent(s)}/categories`, { token });
++}
++
++module.exports = { ForumError, slugOf, verifyManage, list, thread, create, reply, moderate, categories };
ddiff --git a/server/rooms/server-routes.js b/server/rooms/server-routes.js
iindex e87825d..b3f6c51 100644
---- a/server/rooms/server-routes.js
++++ b/server/rooms/server-routes.js
@@@ -12,7 +12,7 @@
* POST /:slug/leave
* GET /:slug/members (moderators also see who is banned)
* POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
-- * POST /:slug/channels owner/admins: { name, kind: text | voice | stage, topic? }
++ * POST /:slug/channels owner/admins: { name, kind: text | voice | forum | stage, community_space?, topic? }
* PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
* DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
* GET /:slug/invites owner/admins: the live invites
@@@ -29,6 +29,7 @@ const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const servers = require('./servers');
const ctx = require('../live-context');
++const communityForum = require('./community-forum');
const router = express.Router();
const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });
@@@ -37,12 +38,23 @@ const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });
function send(res, err) {
if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
++ if (err instanceof communityForum.ForumError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Servers]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
}
const chatServer = () => require('../chat/chat-server');
const roomRoutes = () => require('./routes');
const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };
++const forumToken = (req) => {
++ const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));
++ return m ? m[1].trim() : req.cookies && req.cookies.ov_token || null;
++};
++async function forumChannel(req, res, server) {
++ const room = await channelOf(req, res, server);
++ if (!room) return null;
++ if (!room.forum_space) { res.status(404).json({ error: 'No such forum channel', code: 'forum.not_found' }); return null; }
++ return room;
++}
/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */
async function applyServerChange(server) {
@@@ -156,11 +168,56 @@ router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
const server = await readable(req, res); if (!server) return;
try {
const b = req.body || {};
-- const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });
++ if (b.kind === 'forum' && req.authSource === 'api_token') return res.status(403).json({ error: 'Connect a forum with your own sign-in', code: 'forum.person_only' });
++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) return res.status(403).json({ error: 'Forum channels are not enabled', code: 'servers.forum_disabled' });
++ if (b.kind === 'forum' && !(await servers.access(server, req.user)).manage) return res.status(403).json({ error: 'Only the server owner and admins add channels', code: 'servers.not_admin' });
++ if (b.kind === 'forum') await communityForum.verifyManage(b.community_space, forumToken(req));
++ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic, community_space: b.community_space });
res.status(201).json({ channel: servers.publicChannel(room) });
} catch (err) { send(res, err); }
});
++router.get('/:slug/:channel/forum', optionalAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ try {
++ const out = await communityForum.list(room.forum_space, forumToken(req), req.query);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(out);
++ } catch (err) { send(res, err); }
++});
++router.get('/:slug/:channel/forum/categories', optionalAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.categories(room.forum_space, forumToken(req))); }
++ catch (err) { send(res, err); }
++});
++router.get('/:slug/:channel/forum/:thread', optionalAuth, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await communityForum.thread(room.forum_space, req.params.thread, forumToken(req), { page: req.query.page })); }
++ catch (err) { send(res, err); }
++});
++router.post('/:slug/:channel/forum', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Post with your own sign-in', code: 'forum.person_only' });
++ try { res.status(201).json(await communityForum.create(room.forum_space, forumToken(req), req.body || {})); }
++ catch (err) { send(res, err); }
++});
++router.post('/:slug/:channel/forum/:thread/replies', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Reply with your own sign-in', code: 'forum.person_only' });
++ try { res.status(201).json(await communityForum.reply(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }
++ catch (err) { send(res, err); }
++});
++router.put('/:slug/:channel/forum/:thread/state', requireAuth, manageLimit, async (req, res) => {
++ const server = await readable(req, res); if (!server) return;
++ const room = await forumChannel(req, res, server); if (!room) return;
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Moderate with your own sign-in', code: 'forum.person_only' });
++ try { res.json(await communityForum.moderate(room.forum_space, req.params.thread, forumToken(req), req.body || {})); }
++ catch (err) { send(res, err); }
++});
router.get('/:slug/:channel/stage', optionalAuth, async (req, res) => {
const server = await readable(req, res); if (!server) return;
const room = await stageChannel(req, res, server); if (!room) return;
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 71254cb..f010dc2 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -40,7 +40,7 @@ const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invit
const OFFICIAL_SLUG = 'openvibe';
const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
--const CHANNEL_KINDS = { text: 'community', voice: 'call', stage: 'call' };
++const CHANNEL_KINDS = { text: 'community', voice: 'call', forum: 'community', stage: 'call' };
/** Channel names the server's own pages use (/s/<server>/settings …). */
const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
const MAX_SERVERS_PER_OWNER = 5;
@@@ -134,7 +134,7 @@ function publicServer(s, extra = {}) {
/** A channel as the server shows it. */
function publicChannel(r, extra = {}) {
return {
-- slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',
++ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.forum_space ? 'forum' : r.stage_channel ? 'stage' : r.kind === 'call' ? 'voice' : 'text',
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
category: r.category || null,
...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: r.stage_channel ? 'participant' : rooms.joinRoleOf(r) } : {}), ...extra,
@@@ -160,12 +160,14 @@ async function channelBySlug(server, channelSlug) {
return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;
}
--async function insertChannel(server, user, { name, kind, topic = null, position = null }) {
++async function insertChannel(server, user, { name, kind, topic = null, position = null, community_space = null }) {
const n = cleanName(name, 1, 40);
if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
const roomKind = CHANNEL_KINDS[kind];
-- if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
++ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text, voice, forum or stage');
++ if (kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) fail(403, 'servers.forum_disabled', 'Forum channels are not enabled');
++ if (kind === 'forum' && !require('./community-forum').slugOf(community_space)) fail(422, 'servers.forum_space', 'Connect a valid Community forum address');
if (kind === 'stage' && require('../settings').value('stage_channels_enabled', null, false) !== true) fail(403, 'servers.stage_disabled', 'Stage channels are not enabled');
const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
@@@ -177,9 +179,9 @@ async function insertChannel(server, user, { name, kind, topic = null, position
const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
// The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
-- const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, stage_channel)
-- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,
-- [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'stage']);
++ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position, forum_space, stage_channel)
++ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)`,
++ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? (kind === 'stage' ? 'participant' : 'speaker') : null, server.id, cs, pos, kind === 'forum' ? String(community_space).toLowerCase() : null, kind === 'stage']);
if (kind === 'stage') await db.run('INSERT INTO chat_stage_sessions (room_id, topic) VALUES (?, ?)', [r.lastInsertRowid, t]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
}
@@@ -408,9 +410,9 @@ async function update(server, actor, { name, description, visibility, rules, sel
return await byId(server.id);
}
--async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {
++async function createChannel(server, actor, { name, kind = 'text', topic = null, community_space = null } = {}) {
if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');
-- const room = await insertChannel(server, actor, { name, kind, topic });
++ const room = await insertChannel(server, actor, { name, kind, topic, community_space });
// Every member reads the new channel from its first message on.
await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'
ddiff --git a/server/settings.js b/server/settings.js
iindex f94a471..4bc4f55 100644
---- a/server/settings.js
++++ b/server/settings.js
@@@ -38,6 +38,12 @@ const SCHEMA = Object.freeze({
type: 'object',
additionalProperties: false,
properties: {
++ forum_channels_enabled: {
++ type: 'boolean', title: 'Forum channels', 'x-group': 'Channels', default: false,
++ description: 'Allow server owners to connect channels to Community forums.',
++ 'x-required-for': 'Community backed forum channels',
++ 'x-owner-input': { group: 'voices', unlocks: 'Forum channels backed by Community discussions and the shared search feed.', get_it_at: 'https://openvibe.community', get_it_how: 'This is a Chat feature switch. Enable it to let server owners connect forums they moderate.' },
++ },
stage_channels_enabled: {
type: 'boolean', title: 'Stage channels', 'x-group': 'Channels', default: false,
description: 'Allow server owners to create stage channels. Stage audio uses Chat voice rooms.',
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex f5a5b86..bfbce4c 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -194,6 +194,43 @@ ${sheet}`,
if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
const room = await rooms.bySlug(pick.room);
const rcan = await rooms.access(room, me);
++ if (room.forum_space) {
++ const forum = require('../rooms/community-forum');
++ const bearer = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || ''));
++ const token = bearer ? bearer[1].trim() : req.cookies && req.cookies.ov_token || null;
++ try {
++ const data = req.query.post
++ ? await forum.thread(room.forum_space, req.query.post, token, { page: req.query.page })
++ : await forum.list(room.forum_space, token, req.query);
++ const mine = me ? (await servers.list(me)).mine : [];
++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
++ const categories = data.categories || [];
++ const post = data.thread || null;
++ const itemHref = (slug) => `/s/${server.slug}/${pick.slug}?post=${encodeURIComponent(slug)}`;
++ const sortLinks = `<nav aria-label="Forum order"><a href="?sort=active">Latest activity</a> · <a href="?sort=new">Newest</a></nav>`;
++ const tags = categories.length ? `<nav aria-label="Tags"><a href="?sort=active">All tags</a>${categories.map((tag) => ` · <a href="?category=${encodeURIComponent(tag.slug)}">${esc(tag.name)}</a>`).join('')}</nav>` : '';
++ const searchHref = `${require('../config').community.publicUrl}/search?type=thread&q=${encodeURIComponent(data.space && data.space.name || '')}`;
++ const listMarkup = (data.threads || []).map((t) => `<article><h3><a href="${itemHref(t.slug)}">${esc(t.title)}</a></h3><p>${esc(t.author && (t.author.display_name || t.author.username) || 'Member')} · ${Number(t.reply_count) || 0} replies · ${esc(t.last_activity_at || t.created_at || '')}${t.pinned ? ' · Pinned' : ''}${t.locked ? ' · Locked' : ''}</p>${t.category ? `<p>Tag: ${esc(t.category.name)}</p>` : ''}</article>`).join('') || '<p>No posts yet.</p>';
++ const postMarkup = post ? `<article><h2>${esc(post.title)}</h2><p>${esc(post.author && (post.author.display_name || post.author.username) || 'Member')} · ${esc(post.last_activity_at || post.created_at || '')}${post.pinned ? ' · Pinned' : ''}${post.locked ? ' · Locked' : ''}</p>${(data.posts || []).map((p) => `<section><p>${esc(p.author && (p.author.display_name || p.author.username) || 'Member')} · ${esc(p.created_at || '')}</p><div>${p.body_html || ''}</div></section>`).join('')}<p><a href="${esc(`${require('../config').community.publicUrl}/s/${encodeURIComponent(room.forum_space)}/t/${encodeURIComponent(post.slug)}`)}">Reply or edit in Community</a></p></article>` : `<section aria-label="Forum posts">${listMarkup}</section>`;
++ const forumForm = me && !post ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum"><h3>Start a post</h3><label>Title <input name="title" maxlength="200" required></label><label>Post <textarea name="body" maxlength="40000" rows="5" required></textarea></label>${categories.length ? `<label>Tag <select name="category"><option value="">No tag</option>${categories.map((c) => `<option value="${esc(c.slug)}">${esc(c.name)}</option>`).join('')}</select></label>` : ''}<button class="ovk-btn ovk-btn--primary" type="submit">Post</button></form>` : '';
++ const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';
++ const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';
++ const shellBody = shell.shell({
++ rail: shell.rail({ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })), dmUnread }),
++ side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,
++ categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },
++ { name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},
++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {} }),
++ sideLabel: server.name,
++ main: `${shell.channelHeader({ name: room.name, kind: 'forum', topic: room.topic || data.space && data.space.description, members: true })}<h2>${esc(data.space && data.space.name || 'Forum')}</h2>${sortLinks}${tags}<p><a href="${esc(searchHref)}">Search posts</a></p>${postMarkup}${forumForm}${replyForm}${modForm}`,
++ mainLabel: `Forum: ${room.name}`,
++ members: shell.membersColumn({ groups: [] }), membersLabel: `People in ${server.name}`,
++ });
++ return await page(req, res, 200, { actor, shell: true, footer: 'none', title: `${post ? post.title : 'Forum'} · ${server.name}`, path: req.path, active: 'servers', robots: 'noindex,follow', script: true, description: server.description || `${server.name} forum on OpenVibe.Chat.`, body: shellBody });
++ } catch (err) {
++ return await page(req, res, err.status || 503, { actor, shell: true, title: 'Forum unavailable', path: req.path, active: 'servers', robots: 'noindex', body: `<h1>Forum unavailable</h1><p>${esc(err.message)}</p><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
++ }
++ }
const msgs = await rooms.history(room, { limit: 60 });
if (me && rcan.role) await rooms.markRead(room, me);
const mine = me ? (await servers.list(me)).mine : [];
@@@ -471,7 +508,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
};
const active = people.filter((p) => p.role !== 'blocked');
const banned = people.filter((p) => p.role === 'blocked');
-- const chanRow = (c) => `<li><span>${c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
++ const chanRow = (c) => `<li><span>${c.kind === 'forum' ? '▤' : c.kind === 'stage' ? '◉' : c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
@@@ -499,7 +536,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
--<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select> <button type="submit">Add</button></form>
++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
@@@ -544,7 +581,10 @@ ${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" meth
}));
router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
const b = req.body || {};
-- const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'stage'].includes(b.kind) ? b.kind : 'text' });
++ if (b.kind === 'forum' && require('../settings').value('forum_channels_enabled', null, false) !== true) throw Object.assign(new Error('Forum channels are not enabled'), { status: 403 });
++ if (b.kind === 'forum' && !(await servers.access(server, user)).manage) throw Object.assign(new Error('Only the server owner and admins add channels'), { status: 403 });
++ if (b.kind === 'forum') await require('../rooms/community-forum').verifyManage(b.community_space, req.cookies && req.cookies.ov_token);
++ const room = await servers.createChannel(server, user, { name: b.name, kind: ['voice', 'forum', 'stage'].includes(b.kind) ? b.kind : 'text', community_space: b.community_space });
res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
}));
router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
@@@ -618,11 +658,42 @@ ${errorOf(req)}${action}</section>`,
// A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
router.get('/s/:slug/:channel', serverPage);
++ router.post('/s/:slug/:channel/forum', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/${room.channel_slug}`;
++ if (req.body && req.body.category) req.body.category = String(req.body.category);
++ const token = req.cookies && req.cookies.ov_token;
++ const out = await require('../rooms/community-forum').create(room.forum_space, token, req.body || {});
++ res.redirect(303, `${req.back}?post=${encodeURIComponent(out.thread.slug)}`);
++ }));
++ router.post('/s/:slug/:channel/forum/:thread/replies', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;
++ await require('../rooms/community-forum').reply(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token, req.body || {});
++ res.redirect(303, req.back);
++ }));
++ router.post('/s/:slug/:channel/forum/:thread/state', formLimit, sameSite, orError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const server = await servers.bySlug(req.params.slug);
++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
++ if (!room || !room.forum_space || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
++ req.back = `/s/${server.slug}/${room.channel_slug}?post=${encodeURIComponent(req.params.thread)}`;
++ await require('../rooms/community-forum').moderate(room.forum_space, req.params.thread, req.cookies && req.cookies.ov_token,
++ { pinned: req.body && req.body.pinned === 'true', locked: req.body && req.body.locked === 'true' });
++ res.redirect(303, req.back);
++ }));
router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const server = await servers.bySlug(req.params.slug);
const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
++ if (room.forum_space) return res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
req.back = `/s/${server.slug}/${room.channel_slug}`;
const message = await rooms.post(room, actor.user, req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
ddiff --git a/test/community-forum-client.test.js b/test/community-forum-client.test.js
nnew file mode 100644
iindex 0000000..0a47a6a
---- /dev/null
++++ b/test/community-forum-client.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++const assert = require('assert');
++const { suite } = require('./helpers');
++const forum = require('../server/rooms/community-forum');
++
++const t = suite('community forum client');
++
++t('validates forum connections with the signed-in person token and supports activity, newest and category filters', async () => {
++ assert.strictEqual(forum.slugOf('../private'), null);
++ const oldFetch = global.fetch;
++ const seen = [];
++ global.fetch = async (url, options) => {
++ seen.push({ url: String(url), options });
++ return { ok: true, status: 200, json: async () => ({ space: { slug: 'announcements', style: 'forum' }, viewer: { can_moderate: true }, threads: [] }) };
++ };
++ try {
++ const connected = await forum.verifyManage('announcements', 'user-token');
++ assert.strictEqual(connected.space.slug, 'announcements');
++ assert.strictEqual(seen[0].options.headers.Authorization, 'Bearer user-token');
++ assert.match(seen[0].url, /\/api\/v1\/spaces\/announcements\/threads\?limit=1$/);
++ await forum.list('announcements', 'user-token', { sort: 'new', category: 'updates' });
++ assert.match(seen[1].url, /sort=new/);
++ assert.match(seen[1].url, /category=updates/);
++ } finally { global.fetch = oldFetch; }
++});
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
iindex a8a88cb..9487be4 100644
---- a/test/owner-settings.test.js
++++ b/test/owner-settings.test.js
@@@ -46,7 +46,7 @@ t('the namespace publishes every voice field with owner words', async () => {
assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
}
-- assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json', 'stage_channels_enabled']);
++ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'forum_channels_enabled', 'google_cloud_service_account_json', 'stage_channels_enabled']);
const region = props.amazon_polly_region;
assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
for (const v of region.enum) {
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex b8ec7b4..ac30015 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -41,7 +41,7 @@ const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
-- 'idx_dm_contacts_user',
++ 'idx_dm_contacts_user', 'idx_rooms_forum_space',
'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
];
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 38644a0..4486002 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -116,6 +116,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
++ 'server/rooms/community-forum.js': 'Community API (configured internal URL), fixed space/thread endpoints; slugs and ids are validated and request paths are not user URLs',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex 6813301..1f1cc70 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -109,6 +109,7 @@ t('roles: the owner appoints admins, admins appoint mods, mods remove and ban me
t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Ideas', kind: 'forum', community_space: 'ideas' })).body.code, 'servers.forum_disabled', 'forum channels default off');
assert.strictEqual((await api('POST', '/saturday-raiders/channels', bob, { name: 'Town hall', kind: 'stage' })).body.code, 'servers.stage_disabled', 'stage channels default off');
let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);