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)
Changes
@@@ -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;@@@ -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'); }@@@ -376,11 +376,22 @@ function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Numberlet _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) => {@@@ -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 poliassert.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);@@@ -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);@@@ -216,8 +216,9 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asyawait 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', asyfor (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();
@@ -384,7 +384,10 @@ class ChatServer {
@@ -384,7 +384,10 @@ class ChatServer {
// stream id of its own but posts into the live room).
// stream id of its own but posts into the live room).
const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
const streamSlowMs = (slowStreamId && this.slowModeByStream.get(slowStreamId)) || 0;
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) {
if (now - lastMsg < effectiveLimit) {
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
return;
return;
@@ -427,11 +427,14 @@ function deleteExpiredChatMessages(limit = 500) {
@@ -427,11 +427,14 @@ function deleteExpiredChatMessages(limit = 500) {
return rows;
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) {
function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.
// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.
const where = streamId
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];
const params = streamId ? [streamId, fromTime, toTime] : [fromTime, toTime];
return transaction(() => {
return transaction(() => {
const ids = all(`SELECT id FROM chat_messages WHERE ${where}`, params).map(m => m.id);
const ids = all(`SELECT id FROM chat_messages WHERE ${where}`, params).map(m => m.id);
@@ -449,13 +452,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
@@ -449,13 +452,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
if (streamId) {
if (streamId) {
row = get(
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
`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]
[streamId, fromTime, toTime]
);
);
} else {
} else {
row = get(
row = get(
`SELECT COUNT(*) as cnt FROM chat_messages
`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]
[fromTime, toTime]
);
);
}
}
@@ -469,8 +472,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
@@ -469,8 +472,8 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
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 (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
if (!includeDeleted) { conditions.push('is_deleted = 0'); }
if (!includeDeleted) { conditions.push('is_deleted = 0'); }
@@ -376,11 +376,22 @@ function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number
@@ -376,11 +376,22 @@ function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number
let _bans = { rows: [], version: null, at: 0, cidr: [] };
let _bans = { rows: [], version: null, at: 0, cidr: [] };
let _bansLoading = null;
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 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) {
function _normalizeBanIp(ip) {
let s = String(ip || '').trim();
let s = String(ip || '').trim();
@@ -418,11 +429,16 @@ async function refreshBans() {
@@ -418,11 +429,16 @@ async function refreshBans() {
return _bansLoading;
return _bansLoading;
}
}
function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }
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) {
function isUserBanned(userId, streamId) {
if (!userId) return false;
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)
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));
&& (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
}
}
@@ -430,7 +446,7 @@ function isUserBanned(userId, streamId) {
@@ -430,7 +446,7 @@ function isUserBanned(userId, streamId) {
function getIpBan(ip, streamId) {
function getIpBan(ip, streamId) {
const norm = _normalizeBanIp(ip);
const norm = _normalizeBanIp(ip);
if (!norm) return null;
if (!norm) return null;
- const now = sqliteNow();
+ const now = Date.now();
const b = _bansFresh();
const b = _bansFresh();
const exact = b.rows.find((r) => r.ip_address != null && (r.ip_address === String(ip) || r.ip_address === norm)
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));
&& (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
@@ -786,7 +802,8 @@ const effects = {
@@ -786,7 +802,8 @@ const effects = {
viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },
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 }); },
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; }),
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
// /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).
// does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
/**
/**
* The Live adapter's promises: a chat message makes no read call to Live (only its one effect),
* 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
* 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 assert = require('assert');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
@@ -74,31 +74,47 @@ t('invalidation never leaves a gap: a moderator stays a moderator while the poli
@@ -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);
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 past = h.sqliteNow(-60e3);
const future = h.sqliteNow(3600e3);
const future = h.sqliteNow(3600e3);
h.live.addBan({ user_id: alice.id, stream_id: streamId, expires_at: future });
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({ user_id: mod.id, expires_at: past });
h.live.addBan({ ip_address: '2001:db8:1::/48' });
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 isoPast = new Date(Date.now() - 60e3).toISOString();
const other = h.addUser('other');
const other = h.addUser('other');
h.live.addBan({ user_id: other.id, expires_at: isoPast });
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();
await h.ctx.invalidateBans();
assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), true);
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(alice.id, 999), false, 'stream ban stays in its stream');
assert.strictEqual(h.ctx.isUserBanned(mod.id, streamId), false, 'expired');
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('2001:db8:1:ffff::1', null), true);
assert.strictEqual(h.ctx.isIpBanned('::ffff:10.0.0.1', null), false);
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();
h.live.clearBans();
await h.ctx.invalidateBans();
await h.ctx.invalidateBans();
assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), false);
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 () => {
t('followers-only uses the viewer’s warm follow list', async () => {
h.live.policies.get(channelId).settings = { followers_only: 1 };
h.live.policies.get(channelId).settings = { followers_only: 1 };
h.ctx.invalidateChannel(channelId);
h.ctx.invalidateChannel(channelId);
@@ -130,6 +130,20 @@ t('a time-range purge (REST) and the auto-delete sweep', async () => {
@@ -130,6 +130,20 @@ t('a time-range purge (REST) and the auto-delete sweep', async () => {
assert.strictEqual(r.body.deleted, 2);
assert.strictEqual(r.body.deleted, 2);
assertDeletion(deletions(from)[0], inRoom);
assertDeletion(deletions(from)[0], inRoom);
assert.ok(!h.db.getChatMessageById(global).is_deleted, 'the other room is untouched');
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) });
const expiring = say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });
from = outboxSeq();
from = outboxSeq();
const swept = h.db.deleteExpiredChatMessages(500);
const swept = h.db.deleteExpiredChatMessages(500);
@@ -216,8 +216,9 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
@@ -216,8 +216,9 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
await h.sleep(1100);
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/clear' });
modWs.sendJson({ type: 'chat', message: '/clear' });
await aliceWs.next((m) => m.type === '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);
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/slow 5' });
modWs.sendJson({ type: 'chat', message: '/slow 5' });
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 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
@@ -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);
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));
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'));
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' });
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; });
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();
modWs.close();