openvibers/openvibe.chat Commits

The global room shows no slur spelled a few letters a line, and no mirrored wall or line of symbols (openvibe.chat's front page showed both on 10-24) (#136)

OP OpenVibers committed 21 days ago · b3ac18b406 · 6 files, +202 −7

Changes

ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex fcb0bb4..b9b0195 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
const { clientIpOf } = require('../net/client-ip');
const permissions = require('../auth/permissions');
const wordFilter = require('./word-filter');
++const globalStandard = require('./global-standard');
const ttsEngine = require('./tts-engine');
const soundboard = require('./soundboard-service');
const audioQueue = require('./audio-queue');
@@@ -81,6 +82,8 @@ const {
class ChatServer {
constructor() {
this.wss = null;
++ // What the global room shows of each line (global-standard.js): a slur spelled across one sender's short lines.
++ this.globalRuns = globalStandard.createRunGuard();
/** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */
this.clients = new Map();
/** @type {Map<string, number>} ip → open sockets */
@@@ -1029,9 +1032,13 @@ class ChatServer {
// forwarding so a viewer isn't interrupted when the streamer switches
// slots or briefly drops offline.
await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
-- // Surface on the homepage global feed (tagged with the channel).
-- if (client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);
-- else await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
++ // Surface on the homepage global feed (tagged with the channel), unless OpenVibe's global room does not
++ // show it (global-standard.js): the line stays in its own room either way. A line that completes a slur
++ // spelled across the sender's short lines also takes the earlier ones off the global feed.
++ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: true });
++ if (shown.retract.length) await this.broadcastGlobal({ type: 'delete-messages', ids: shown.retract });
++ if (shown.ok && client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);
++ else if (shown.ok) await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
// Auto-translate (async): foreign → English for everyone, English → the channel's
// language for a non-English streamer. Lands as a follow-up 'chat_translation'.
await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
@@@ -1062,8 +1069,17 @@ class ChatServer {
} else if (!client.channelUserId) {
// Pure global chat (homepage) — offline channel messages were already
-- // delivered to the channel room above.
-- await this.broadcastGlobal(chatMsg);
++ // delivered to the channel room above. A slur spelled a few letters a line is refused like one said whole:
++ // this line is not shown and it and the run's earlier lines are deleted (and taken off every global feed).
++ const shown = this.globalRuns.check({ id: chatMsg.id, message: text, user_id: client.user?.id, anon_id: client.anonId, username, mirrored: false });
++ if (shown.retract.length && !permissions.can(client.user, 'staff.moderation.bypass')) {
++ const ids = [...shown.retract, chatMsg.id].filter(Boolean);
++ for (const id of ids) await db.deleteChatMessage(id, null).catch(() => {});
++ await this.broadcastGlobal({ type: 'delete-messages', ids });
++ this.sendTo(ws, { type: 'system', message: GLOBAL_SLUR_REFUSED });
++ } else {
++ await this.broadcastGlobal(chatMsg);
++ }
} else if (this._channelOwnerInRoom(client.channelUserId)) {
// OFFLINE channel chat with the channel OWNER present in their own room:
// synthesize TTS so their "TTS on my channel even when offline" option has
ddiff --git a/server/chat/global-standard.js b/server/chat/global-standard.js
nnew file mode 100644
iindex 0000000..f48d26d
---- /dev/null
++++ b/server/chat/global-standard.js
@@@ -0,0 +1,86 @@
++'use strict';
++/**
++ * What OpenVibe's own global room shows: the openvibe.chat front page, /global and the global feed on every site, where
++ * every stream's and channel's chat is mirrored (chat-server forwardToGlobal / forwardToGlobalByChannel). A stream or
++ * channel room keeps its streamer's own rules; the global room has OpenVibe's: no core slur (moderation-utils), which
++ * global chat already refuses at send time one line at a time.
++ *
++ * 2026-10-24: a viewer flooded goosely's offline channel with 600–1,378-character walls of block characters, then spelled
++ * a slur one letter per line; all of it showed on openvibe.chat's front page. So here:
++ * - a core slur spelled across one sender's short lines (each at most SHORT visible characters, at most RUN_MS
++ * apart) hides the whole run;
++ * - a mirrored line (from a stream or channel room) is not shown when it is a wall (long, mostly not letters or
++ * digits) or has no letter, digit or emoji at all (".", ",", "%%%%").
++ * The lines stay in their own room. The front page and the global history filter rows (filterFeed); the live mirror
++ * asks a run guard per line (createRunGuard).
++ */
++const { containsCoreSlur } = require('./moderation-utils');
++
++const SHORT = 4;
++const RUN_MS = 60 * 1000;
++const WALL_MIN = 120;
++const WORDISH = /[\p{L}\p{N}\p{Extended_Pictographic}]/gu;
++
++const visible = (text) => [...String(text == null ? '' : text).replace(/\s+/g, '')];
++const isShort = (text) => { const n = visible(text).length; return n > 0 && n <= SHORT; };
++function isWall(text) {
++ const v = visible(text);
++ if (v.length < WALL_MIN) return false;
++ return (v.join('').match(WORDISH) || []).length / v.length < 0.3;
++}
++const hasWords = (text) => /[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(String(text || ''));
++const senderOf = (m) => (m.user_id ? `u:${m.user_id}` : m.anon_id ? `a:${m.anon_id}` : `n:${m.username || ''}`);
++const mirroredRow = (m) => !!(m.stream_id || m.channel_user_id) && !m.is_global;
++const atOf = (m) => { const s = String(m.timestamp || ''); const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : 0; };
++
++/** One line on its own: `mirrored` is a line from a stream or channel room. */
++function lineOk(text, { mirrored = false } = {}) {
++ if (containsCoreSlur(String(text || ''), [])) return false;
++ if (mirrored && (isWall(text) || !hasWords(text))) return false;
++ return true;
++}
++
++/** Rows of the global feed without what the global room does not show, in the order given. */
++function filterFeed(rows) {
++ const list = Array.isArray(rows) ? rows : [];
++ const drop = new Set();
++ const runs = new Map(); // sender → its current run of short lines, oldest first
++ const close = (run) => { if (run && run.length > 1 && containsCoreSlur(run.map((r) => r.message).join(' '), [])) run.forEach((r) => drop.add(r)); };
++ for (const r of [...list].sort((a, b) => Number(a.id) - Number(b.id))) {
++ if ((r.message_type || 'chat') !== 'chat') continue;
++ const key = senderOf(r);
++ const run = runs.get(key);
++ if (!isShort(r.message)) { close(run); runs.delete(key); continue; }
++ if (run && atOf(r) - atOf(run[run.length - 1]) <= RUN_MS) run.push(r);
++ else { close(run); runs.set(key, [r]); }
++ }
++ for (const run of runs.values()) close(run);
++ return list.filter((r) => !drop.has(r) && ((r.message_type || 'chat') !== 'chat' || lineOk(r.message, { mirrored: mirroredRow(r) })));
++}
++
++/**
++ * The live side: check(line) as each line is said → { ok, retract }. ok = it may be shown in the global room now;
++ * retract = the ids of the sender's earlier lines that this one turns into a slur (shown already: delete them there).
++ * line = { id, message, user_id?, anon_id?, username?, mirrored }.
++ */
++function createRunGuard({ now = Date.now } = {}) {
++ const runs = new Map(); // sender → [{ id, text, at }]
++ return {
++ check(line) {
++ const key = senderOf(line);
++ const at = now();
++ if (!isShort(line.message)) { runs.delete(key); return { ok: lineOk(line.message, line), retract: [] }; }
++ const run = (runs.get(key) || []).filter((x) => at - x.at <= RUN_MS);
++ run.push({ id: line.id, text: String(line.message), at });
++ if (run.length > 1 && containsCoreSlur(run.map((x) => x.text).join(' '), [])) {
++ runs.delete(key);
++ return { ok: false, retract: run.slice(0, -1).map((x) => x.id).filter(Boolean) };
++ }
++ runs.set(key, run.slice(-24));
++ if (runs.size > 5000) runs.delete(runs.keys().next().value);
++ return { ok: lineOk(line.message, line), retract: [] };
++ },
++ };
++}
++
++module.exports = { lineOk, filterFeed, createRunGuard, isWall, SHORT, RUN_MS };
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex b41c838..c203ea0 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -520,7 +520,8 @@ router.get('/global/history', optionalAuth, async (req, res) => {
const out = afterId != null
? await historyStore.delta('global', { afterId, limit, channelUsername })
: await historyStore.page('global', { limit, before, channelUsername });
-- out.messages = await withoutBlocked(req, res, out.messages);
++ // What OpenVibe's global room shows of other rooms' lines (global-standard.js), then not the reader's blocked people.
++ out.messages = await withoutBlocked(req, res, require('./global-standard').filterFeed(out.messages));
await loadDecor(out.messages);
out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));
res.json(out);
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 3fe1b8a..886f63d 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -215,7 +215,9 @@ function createWebRoutes({ config }) {
const { messages, latest_id } = await historyStore.page('global', { limit: 60 });
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
-- const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
++ // And not what OpenVibe's global room does not show of other rooms' lines (chat/global-standard.js).
++ const shown = require('../chat/global-standard').filterFeed(messages)
++ .filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
// What people wear: their name effects, from the decor cache (one Live read per 200 people, kept a minute). The
// page waits for that read a quarter of a second at most: Live slow or down never holds the page, the names just
// stay plain until the cache is warm.
ddiff --git a/test/global-standard.test.js b/test/global-standard.test.js
nnew file mode 100644
iindex 0000000..77404e9
---- /dev/null
++++ b/test/global-standard.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * server/chat/global-standard.js: what OpenVibe's global room shows of every room's lines. A slur spelled across one
++ * sender's short lines hides the run (rows) or retracts it (live); a mirrored wall or a line without a letter, digit
++ * or emoji is not shown; everything ordinary is.
++ */
++const assert = require('assert');
++const { lineOk, filterFeed, createRunGuard, isWall } = require('../server/chat/global-standard');
++
++const letters = ['k', 'y', 'k', 'e']; // a built-in pattern, spelled out at run time
++const slur = letters.join('');
++let id = 0;
++const row = (message, extra = {}) => ({ id: ++id, message, message_type: 'chat', user_id: 7, channel_user_id: 3, is_global: 0, timestamp: `2026-10-24 13:58:${String(id % 60).padStart(2, '0')}`, ...extra });
++
++// One line.
++assert.strictEqual(lineOk(`you ${slur}`), false);
++assert.strictEqual(lineOk('hello there'), true);
++assert.strictEqual(lineOk('.', { mirrored: true }), false, 'a mirrored line of punctuation');
++assert.strictEqual(lineOk('.', { mirrored: false }), true, 'the global room keeps its own short lines');
++assert.strictEqual(lineOk('😂', { mirrored: true }), true, 'an emoji is a line');
++assert.strictEqual(lineOk('gg', { mirrored: true }), true);
++assert.ok(isWall('░▒▓'.repeat(60)) && isWall('#'.repeat(600)), 'block characters and symbols');
++assert.ok(!isWall('word '.repeat(60)) && !isWall('😂'.repeat(150)) && !isWall('ok'), 'long words or emoji are not a wall');
++
++// Rows: the run goes whole, an interleaved sender does not break it, another sender's letters are their own.
++const run = letters.map((l) => row(l));
++const others = [row('k', { user_id: 9 }), row('nice stream', { user_id: 9 })];
++const feed = [run[0], others[0], run[1], others[1], run[2], run[3], row('░▒▓'.repeat(60)), row('normal line')];
++const kept = filterFeed(feed).map((r) => r.message);
++assert.deepStrictEqual(kept, ['k', 'nice stream', 'normal line'], 'the run and the wall are left out, the rest kept in order');
++assert.deepStrictEqual(filterFeed([...feed].reverse()).map((r) => r.message), ['normal line', 'nice stream', 'k'], 'newest first works the same');
++// A run more than a minute apart is two runs.
++const slow = letters.map((l, i) => row(l, { user_id: 11, timestamp: `2026-10-24 14:0${i * 2}:00` }));
++assert.strictEqual(filterFeed(slow).length, 4, 'letters minutes apart are not a run');
++assert.deepStrictEqual(filterFeed([row('/me waves', { message_type: 'system' })]).length, 1, 'non-chat rows pass through');
++
++// Live: the line that completes the run is refused and retracts the earlier ones.
++let t = 0;
++const guard = createRunGuard({ now: () => t });
++const said = letters.map((l, i) => { t += 1000; return guard.check({ id: 100 + i, message: l, user_id: 5, mirrored: true }); });
++assert.deepStrictEqual(said.slice(0, 3).map((x) => x.ok), [true, true, true]);
++assert.deepStrictEqual(said[3], { ok: false, retract: [100, 101, 102] });
++t += 1000;
++assert.deepStrictEqual(guard.check({ id: 200, message: 'k', user_id: 5, mirrored: true }), { ok: true, retract: [] }, 'a fresh run after it');
++t += 120000;
++assert.deepStrictEqual(guard.check({ id: 201, message: 'y', user_id: 5, mirrored: true }).retract, [], 'two minutes later the run is gone');
++assert.strictEqual(guard.check({ id: 202, message: 'a whole sentence', user_id: 5, mirrored: true }).ok, true);
++assert.strictEqual(guard.check({ id: 203, message: '#'.repeat(300), user_id: 5, mirrored: true }).ok, false, 'a mirrored wall');
++
++console.log('global-standard: all checks passed');
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 8d9057d..0414d5b 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -131,6 +131,46 @@ t('global chat refuses the built-in slurs, /me included; an offline channel room
for (const w of [reader, bobG, own]) w.close();
});
++t('the global feed does not mirror a slur spelled a letter a line, a wall or a line of symbols from a channel room; in global chat such a run is deleted', async () => {
++ // 2026-10-24: walls of block characters, then a slur one letter per line in goosely's offline channel, all on
++ // openvibe.chat's front page. The lines stay in their own room (the streamer's rules); the global room has OpenVibe's.
++ const letters = ['k', 'y', 'k', 'e']; // a built-in pattern, spelled out at run time as above
++ const reader = await joined({ ip: '198.51.100.43', token: streamer.token }, {});
++ const own = await joined({ ip: '198.51.100.44', token: bob.token }, { channelUserId: other.id });
++ const said = async (ws, line) => { ws.sendJson({ type: 'chat', message: line }); await h.sleep(1100); };
++ const wall = '\u2591\u2592\u2593'.repeat(60);
++ await said(own, wall);
++ await said(own, '%%%%%%');
++ assert.ok(own.all.some((m) => m.type === 'chat' && m.message === wall), 'the wall stays in its own room');
++ assert.ok(reader.all.every((m) => m.message !== wall && m.message !== '%%%%%%'), 'a wall or a line of symbols reached the global feed');
++ for (const l of letters.slice(0, -1)) await said(own, l);
++ const mirrored = reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).map((m) => m.id);
++ assert.strictEqual(mirrored.length, 3, 'single letters on their own are mirrored');
++ own.sendJson({ type: 'chat', message: letters[letters.length - 1] });
++ const gone = await reader.next((m) => m.type === 'delete-messages');
++ assert.deepStrictEqual(gone.ids.map(Number).sort(), mirrored.map(Number).sort(), 'the run is taken off the global feed');
++ await h.sleep(300);
++ assert.strictEqual(reader.all.filter((m) => m.type === 'chat' && letters.includes(m.message)).length, 3, 'the last letter is not mirrored');
++
++ // Global chat itself: the run is refused like a slur said whole, and its lines are deleted.
++ await h.sleep(1100);
++ const bobG = await joined({ ip: '198.51.100.45', token: bob.token }, {});
++ for (const l of letters.slice(0, -1)) await said(bobG, l);
++ bobG.sendJson({ type: 'chat', message: letters[letters.length - 1] });
++ await bobG.next((m) => m.type === 'system' && /global chat does not allow slurs/.test(m.message));
++ const deleted = await reader.next((m) => m.type === 'delete-messages' && m.ids.length === 4);
++ for (const id of deleted.ids) assert.strictEqual(Number((await h.db.get('SELECT is_deleted FROM chat_messages WHERE id = ?', [id])).is_deleted), 1);
++
++ // The history the front page and every site's global feed read: none of it.
++ const hist = await (await fetch(`${h.base}/api/chat/global/history?limit=200`)).json();
++ const lines = hist.messages.map((m) => m.message);
++ assert.ok(!lines.includes(wall) && !lines.includes('%%%%%%'), 'history shows the wall');
++ assert.ok(!hist.messages.some((m) => letters.includes(m.message)), 'history shows the spelled run');
++ const page = await (await fetch(`${h.base}/`)).text();
++ assert.ok(!page.includes('\u2591\u2592\u2593\u2591'), 'the front page shows the wall');
++ for (const w of [reader, own, bobG]) w.close();
++});
++
t('joining the channel room without the stream id does not skip slow mode or IP approval', async () => {
const own = await joined({ ip: '198.51.100.30', token: streamer.token, stream: streamX }, { streamId: streamX });
own.sendJson({ type: 'chat', message: '/slow 30' });