openvibers/openvibe.chat Commits

Moderation found by the parity script (WS-I task 6): timeouts end on time, a ban holds from the moment the moderator is answered, the dashboard's range purge matches its range, and moderators are not slowed. (1) live-context.js read a ban's expires_at as TEXT against SQLite's 'YYYY-MM-DD HH:MM:SS', and /timeout stores ISO ('...T...Z', 'T' sorts after ' '), so a 60-second timeout lasted until midnight UTC; it is read as a UTC instant now (either format; a value that is not a date keeps the TEXT comparison). Live's own ban queries still compare TEXT. (2) The ban effect started the ban-cache refresh without waiting for it, and a refresh already in flight answered for it, so the target's next line could still go out; the refresh now finishes (a new read after any in-flight one) before /ban and /timeout answer. (3) The purge, its preview and the log filter compared the dashboard's ISO bounds with SQLite timestamps as TEXT: a same-day range matched nothing and a range ending on a day matched all of it; they use datetime(?). (4) Slow mode slowed moderators too, so one could not even turn it off inside the window; the room's moderators (broadcaster, channel mods, chat staff) keep the 1 s flood limit only. Tests: live-context (ISO and SQLite expiry, a ban in the cache when the effect answers with a refresh in flight), message-deleted (an ISO range: preview, logs, purge, a row outside it stays), ws-protocol (a viewer is slowed, the moderator turns it off 1.1 s in)

OP OpenVibers committed 26 Sept 2026 · e26e063f08 · 6 files, +86 −26


Changes

ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 93638f3..ca4cb4a 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -384,7 +384,10 @@ class ChatServer {
// stream id of its own but posts into the live room).
const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
const streamSlowMs = (slowStreamId && this.slowModeByStream.get(slowStreamId)) || 0;
-- const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
++ // The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep
++ // the flood limit only, so they can moderate and turn slow mode off again at once.
++ const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);
++ const effectiveLimit = slowExempt ? this.DEFAULT_RATE_LIMIT_MS : Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
if (now - lastMsg < effectiveLimit) {
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
return;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 83fd6ef..5a0293c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -427,11 +427,14 @@ function deleteExpiredChatMessages(limit = 500) {
return rows;
}
++// Time ranges (purge, its preview, the log filter): the dashboard sends ISO instants ('…T…Z') and
++// rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a range
++// matched nothing on its first day and all of its last; datetime(?) reads both forms as UTC.
function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.
const where = streamId
-- ? 'stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0'
-- : 'is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0';
++ ? 'stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0'
++ : 'is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0';
const params = streamId ? [streamId, fromTime, toTime] : [fromTime, toTime];
return transaction(() => {
const ids = all(`SELECT id FROM chat_messages WHERE ${where}`, params).map(m => m.id);
@@@ -449,13 +452,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
if (streamId) {
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
-- WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[streamId, fromTime, toTime]
);
} else {
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
-- WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[fromTime, toTime]
);
}
@@@ -469,8 +472,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
-- if (from) { conditions.push('timestamp >= ?'); params.push(from); }
-- if (to) { conditions.push('timestamp <= ?'); params.push(to); }
++ if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }
++ if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }
if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
if (!includeDeleted) { conditions.push('is_deleted = 0'); }
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 8e2f21a..5e173cc 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -376,11 +376,22 @@ function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number
let _bans = { rows: [], version: null, at: 0, cidr: [] };
let _bansLoading = null;
--// Live compares expires_at with SQLite's CURRENT_TIMESTAMP as TEXT. Timeouts store an ISO string
--// ('…T…Z'), which sorts after the same day's 'YYYY-MM-DD HH:MM:SS'; the comparison is kept
--// byte-for-byte so a ban lasts exactly as long as it did when Live evaluated it.
++// A ban ends at expires_at, read as a UTC instant: /timeout stores an ISO string ('…T…Z'), other
++// writers SQLite's 'YYYY-MM-DD HH:MM:SS'. Live compares the TEXT with CURRENT_TIMESTAMP, where
++// 'T' sorts after ' ', so a 60-second timeout lasted until the end of that UTC day; Chat, which
++// enforces chat bans, ends it on time (parity script, roadmap WS-I task 6). A value that is not a
++// date keeps Live's TEXT comparison.
function sqliteNow() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
--function activeBan(r, now) { return r.expires_at == null || String(r.expires_at) > now; }
++const BAN_END_RE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(Z|[+-]\d{2}:?\d{2})?$/;
++function banEndsAt(v) {
++ const m = BAN_END_RE.exec(String(v).trim());
++ return m ? Date.parse(`${m[1]}T${m[2]}${m[3] || 'Z'}`) : NaN;
++}
++function activeBan(r, nowMs) {
++ if (r.expires_at == null) return true;
++ const end = banEndsAt(r.expires_at);
++ return Number.isFinite(end) ? end > nowMs : String(r.expires_at) > sqliteNow();
++}
function _normalizeBanIp(ip) {
let s = String(ip || '').trim();
@@@ -418,11 +429,16 @@ async function refreshBans() {
return _bansLoading;
}
function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }
--function invalidateBans() { _bans.version = null; _bans.at = 0; return refreshBans(); }
++// A fresh read that starts after the call: a load already in flight may have left before a ban
++// was written, so it is waited for and followed by another.
++function invalidateBans() {
++ const reload = () => { _bans.version = null; _bans.at = 0; return refreshBans(); };
++ return _bansLoading ? _bansLoading.then(reload) : reload();
++}
function isUserBanned(userId, streamId) {
if (!userId) return false;
-- const now = sqliteNow();
++ const now = Date.now();
return _bansFresh().rows.some((r) => r.user_id != null && Number(r.user_id) === Number(userId)
&& (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
}
@@@ -430,7 +446,7 @@ function isUserBanned(userId, streamId) {
function getIpBan(ip, streamId) {
const norm = _normalizeBanIp(ip);
if (!norm) return null;
-- const now = sqliteNow();
++ const now = Date.now();
const b = _bansFresh();
const exact = b.rows.find((r) => r.ip_address != null && (r.ip_address === String(ip) || r.ip_address === norm)
&& (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
@@@ -786,7 +802,8 @@ const effects = {
viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },
viewerSnapshot(streamId, count, chatActivity) { _snapshots.push({ stream_id: streamId, viewer_count: count, chat_messages_5m: chatActivity }); },
setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
-- ban: (body) => effect('ban', body).then((r) => { invalidateBans(); return r; }),
++ // The ban is in Chat's cache before the moderator is answered (and before their next check).
++ ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
// /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat
// does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
ddiff --git a/test/live-context.test.js b/test/live-context.test.js
iindex 3f056ff..436de08 100644
---- a/test/live-context.test.js
++++ b/test/live-context.test.js
@@@ -2,8 +2,8 @@
/**
* The Live adapter's promises: a chat message makes no read call to Live (only its one effect),
* warm caches answer while Live is slow or down (stale values, no gaps on invalidation), and ban
-- * evaluation matches Live's SQL — including the TEXT comparison of expires_at that makes an ISO
-- * timeout outlast its duration until the end of that UTC day.
++ * evaluation matches Live's SQL, except that a ban ends at its expires_at instant: Live's TEXT
++ * comparison kept an ISO timeout ('…T…Z') active until the end of that UTC day.
*/
const assert = require('assert');
const { boot, suite } = require('./helpers');
@@@ -74,31 +74,47 @@ t('invalidation never leaves a gap: a moderator stays a moderator while the poli
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
});
--t('bans: user/stream/site scope, CIDR, and Live’s TEXT comparison of expires_at', async () => {
++t('bans: user/stream/site scope, CIDR, and expires_at read as an instant (ISO or SQLite format)', async () => {
const past = h.sqliteNow(-60e3);
const future = h.sqliteNow(3600e3);
h.live.addBan({ user_id: alice.id, stream_id: streamId, expires_at: future });
h.live.addBan({ user_id: mod.id, expires_at: past });
h.live.addBan({ ip_address: '2001:db8:1::/48' });
-- // A timeout written as ISO (what /timeout stores) a minute AGO still sorts after today's
-- // 'YYYY-MM-DD HH:MM:SS' ('T' > ' '), so Live treated it as active until the day ended.
++ // A timeout written as ISO (what /timeout stores) a minute AGO sorts after today's
++ // 'YYYY-MM-DD HH:MM:SS' ('T' > ' '), so Live's TEXT comparison kept it active until the day
++ // ended. Chat reads the instant: it is over. One ending in a minute is still on.
const isoPast = new Date(Date.now() - 60e3).toISOString();
const other = h.addUser('other');
h.live.addBan({ user_id: other.id, expires_at: isoPast });
++ const third = h.addUser('third');
++ h.live.addBan({ user_id: third.id, expires_at: new Date(Date.now() + 60e3).toISOString() });
++ const weird = h.addUser('weird');
++ h.live.addBan({ user_id: weird.id, expires_at: 'not a date' });
await h.ctx.invalidateBans();
assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), true);
assert.strictEqual(h.ctx.isUserBanned(alice.id, 999), false, 'stream ban stays in its stream');
assert.strictEqual(h.ctx.isUserBanned(mod.id, streamId), false, 'expired');
assert.strictEqual(h.ctx.isIpBanned('2001:db8:1:ffff::1', null), true);
assert.strictEqual(h.ctx.isIpBanned('::ffff:10.0.0.1', null), false);
-- if (isoPast.slice(0, 10) === new Date().toISOString().slice(0, 10)) {
-- assert.strictEqual(h.ctx.isUserBanned(other.id, null), true, 'same-day ISO expiry behaves as in Live');
-- }
++ assert.strictEqual(h.ctx.isUserBanned(other.id, null), false, 'an ISO timeout that ended a minute ago is over (Live kept it until midnight UTC)');
++ assert.strictEqual(h.ctx.isUserBanned(third.id, null), true, 'an ISO timeout ending in a minute is on');
++ assert.strictEqual(h.ctx.isUserBanned(weird.id, null), true, 'a value that is not a date keeps Live’s TEXT comparison');
h.live.clearBans();
await h.ctx.invalidateBans();
assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), false);
});
++t('a ban written through Live is in Chat’s cache when the effect answers, even with a refresh in flight', async () => {
++ const target = h.addUser('target');
++ const inFlight = h.ctx.refreshBans(); // left before the ban is written: it cannot have it
++ await h.ctx.effects.ban({ action: 'ban', user_id: target.id, stream_id: streamId, actor_user_id: mod.id, moderation_stream_id: streamId, reason: 'test', banned_by: mod.id });
++ assert.strictEqual(h.ctx.isUserBanned(target.id, streamId), true, 'in effect by the time the moderator is answered');
++ await inFlight;
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++ assert.strictEqual(h.ctx.isUserBanned(target.id, streamId), false);
++});
++
t('followers-only uses the viewer’s warm follow list', async () => {
h.live.policies.get(channelId).settings = { followers_only: 1 };
h.ctx.invalidateChannel(channelId);
ddiff --git a/test/message-deleted.test.js b/test/message-deleted.test.js
iindex 32ef552..d6f9c07 100644
---- a/test/message-deleted.test.js
++++ b/test/message-deleted.test.js
@@@ -130,6 +130,20 @@ t('a time-range purge (REST) and the auto-delete sweep', async () => {
assert.strictEqual(r.body.deleted, 2);
assertDeletion(deletions(from)[0], inRoom);
assert.ok(!h.db.getChatMessageById(global).is_deleted, 'the other room is untouched');
++ // The dashboard sends ISO instants ('…T…Z'); rows keep 'YYYY-MM-DD HH:MM:SS'. Read as TEXT,
++ // a same-day range matched nothing. Preview, purge and the log filter read both forms.
++ const older = say('s-older', { stream_id: streamId, is_global: 0 });
++ h.db.run('UPDATE chat_messages SET timestamp = ? WHERE id = ?', [h.sqliteNow(-10 * 60e3), older]);
++ const recent = [say('s3', { stream_id: streamId, is_global: 0 }), say('s4', { stream_id: streamId, is_global: 0 })];
++ const range = { streamId, from: new Date(Date.now() - 60e3).toISOString(), to: new Date(Date.now() + 60e3).toISOString() };
++ assert.strictEqual((await h.http('POST', '/api/chat/admin/purge/preview', { token: admin.token, body: range })).body.count, 2);
++ const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&from=${encodeURIComponent(range.from)}&to=${encodeURIComponent(range.to)}`, { token: admin.token });
++ assert.deepStrictEqual(logs.body.rows.map((m) => m.id).sort(), [...recent].sort(), 'the log filter reads ISO too');
++ from = outboxSeq();
++ const iso = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: range });
++ assert.strictEqual(iso.body.deleted, 2, iso.text);
++ assertDeletion(deletions(from)[0], recent);
++ assert.ok(!h.db.getChatMessageById(older).is_deleted, 'ten minutes ago is outside the range');
const expiring = say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });
from = outboxSeq();
const swept = h.db.deleteExpiredChatMessages(500);
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 7a31139..277f51b 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -216,8 +216,9 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/clear' });
await aliceWs.next((m) => m.type === 'clear');
-- // /slow: slowmode event + system line to the room, persisted through Live (it applies to
-- // everyone's next message, moderators included — Live's rate limit runs before commands).
++ // /slow: slowmode event + system line to the room, persisted through Live. It applies to
++ // viewers; the room's moderators keep only the 1 s flood limit (the check runs before commands,
++ // so a slowed moderator could not even turn it off).
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/slow 5' });
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
@@@ -230,7 +231,13 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
for (const a of ['channel_ban', 'channel_unban', 'channel_timeout', 'slowmode_update', 'clear_chat']) assert.ok(actions.includes(a), a);
const modEv = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((r) => JSON.parse(r.event));
assert.ok(modEv.length >= 5 && modEv.every((e) => e.visibility === 'internal'));
-- await h.sleep(5100);
++ aliceWs.sendJson({ type: 'chat', message: 'slow one' });
++ await bobWs.next((m) => m.type === 'chat' && m.message === 'slow one');
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: 'slow two' });
++ await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow down! You are sending messages too fast.');
++ assert.ok(await bobWs.none((m) => m.type === 'chat' && m.message === 'slow two'), 'a viewer is slowed');
++ // The moderator, 1.1 s after /slow 5 (inside the 5 s window), is not.
modWs.sendJson({ type: 'chat', message: '/slow off' });
await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode disabled.').catch((e) => { console.log('MOD', JSON.stringify(modWs.all.slice(-6))); throw e; });
modWs.close();