ban system

This commit is contained in:
legop3
2026-01-23 04:12:38 -05:00
parent ba4cf80f41
commit 987ef29f30
23 changed files with 1268 additions and 133 deletions
+14 -3
View File
@@ -8,6 +8,7 @@ const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const { isBannedSocket } = require('./moderationService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
@@ -335,7 +336,9 @@ function sendExternalTyping({
}
io.on('connection', (socket) => {
socket.emit('chat:init', history);
if (!isBannedSocket(socket)) {
socket.emit('chat:init', history);
}
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
const isTyping = Boolean(payload?.isTyping);
@@ -364,12 +367,20 @@ io.on('connection', (socket) => {
subscribe('chat:message', ({ payload }) => {
if (!payload) return;
io.emit('chat:message', payload);
io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:message', payload);
}
});
});
subscribe('chat:typing', ({ payload }) => {
if (!payload) return;
io.emit('chat:typing', payload);
io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:typing', payload);
}
});
});
module.exports = {
+150 -1
View File
@@ -21,6 +21,7 @@ const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService');
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
const { getModerationSnapshot, applyBan, applyUnban } = require('./moderationService');
const {
getGuildConfig,
listGuildConfigs,
@@ -258,10 +259,138 @@ function formatHelp() {
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs goal [text|clear]` — show or set community goal',
'`rs ban <id> [reason]` — ban a user',
'`rs timeout <id> <duration> [reason]` — timeout a user',
'`rs unban <id>` — remove a ban/timeout',
'`rs users` — list recent users',
'`rs bans` — list active bans/timeouts',
'`ts` — show time status',
].join('\n');
}
function parseDuration(input) {
if (!input) return null;
const match = String(input).trim().match(/^(\d+)(s|m|h|d)?$/i);
if (!match) return null;
const value = Number(match[1]);
const unit = (match[2] || 'm').toLowerCase();
const multipliers = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
const ms = value * (multipliers[unit] || 0);
return Number.isFinite(ms) && ms > 0 ? ms : null;
}
function formatModerationDuration(ms) {
if (!ms) return 'permanent';
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.ceil(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.ceil(hours / 24);
return `${days}d`;
}
async function handleBanCommand(message, tokens) {
const target = tokens.shift();
if (!target) {
await message.reply('Usage: `rs ban <id> [reason]`');
return;
}
const reason = tokens.join(' ').trim() || null;
try {
const ban = applyBan(target, {
reason,
createdBy: `discord:${message.author.id}`,
});
await message.reply(
`Banned **${sanitizeMentions(target)}**${reason ? `${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
);
} catch (err) {
await message.reply(`Ban failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleTimeoutCommand(message, tokens) {
const target = tokens.shift();
const durationRaw = tokens.shift();
if (!target || !durationRaw) {
await message.reply('Usage: `rs timeout <id> <duration> [reason]`');
return;
}
const durationMs = parseDuration(durationRaw);
if (!durationMs) {
await message.reply('Invalid duration. Use formats like `30m`, `2h`, or `1d`.');
return;
}
const reason = tokens.join(' ').trim() || null;
try {
const ban = applyBan(target, {
durationMs,
reason,
createdBy: `discord:${message.author.id}`,
});
await message.reply(
`Timed out **${sanitizeMentions(target)}** for ${formatModerationDuration(durationMs)}${reason ? `${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
);
} catch (err) {
await message.reply(`Timeout failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleUnbanCommand(message, tokens) {
const target = tokens.shift();
if (!target) {
await message.reply('Usage: `rs unban <id>`');
return;
}
try {
const removed = applyUnban(target);
if (removed) {
await message.reply(`Unbanned **${sanitizeMentions(target)}**.`);
} else {
await message.reply(`No ban found for **${sanitizeMentions(target)}**.`);
}
} catch (err) {
await message.reply(`Unban failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleUsersCommand(message) {
const snapshot = getModerationSnapshot();
const users = snapshot.users || [];
const recent = users
.filter((user) => user.lastSeen)
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
.slice(0, 10);
if (!recent.length) {
await message.reply('No recent users.');
return;
}
const lines = recent.map((user) => {
const name = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
const status = user.ban ? 'BANNED' : 'ok';
return `${sanitizeMentions(name)} (${user.id.slice(0, 6)}) — ${status}`;
});
await message.reply(lines.join('\n'));
}
async function handleBansCommand(message) {
const snapshot = getModerationSnapshot();
const bans = snapshot.bans || [];
if (!bans.length) {
await message.reply('No active bans/timeouts.');
return;
}
const lines = bans.slice(0, 10).map((ban) => {
const expiresIn = ban.expiresAt ? Math.max(0, ban.expiresAt - Date.now()) : null;
return `${ban.id.slice(0, 6)} ${ban.userId ? `user ${ban.userId.slice(0, 6)}` : 'target'}${
expiresIn ? `timeout ${formatModerationDuration(expiresIn)}` : 'ban'
}`;
});
await message.reply(lines.join('\n'));
}
function findRoverRecord(id) {
if (!id) return null;
for (const record of rovers.values()) {
@@ -695,7 +824,12 @@ async function handleCommand(message) {
action !== 'help' &&
action !== 'replay' &&
action !== 'bridge' &&
action !== 'goal'
action !== 'goal' &&
action !== 'ban' &&
action !== 'timeout' &&
action !== 'unban' &&
action !== 'users' &&
action !== 'bans'
) {
return; // ignore non-admins for privileged commands
}
@@ -728,6 +862,21 @@ async function handleCommand(message) {
case 'goal':
await handleGoalCommand(message, tokens);
break;
case 'ban':
await handleBanCommand(message, tokens);
break;
case 'timeout':
await handleTimeoutCommand(message, tokens);
break;
case 'unban':
await handleUnbanCommand(message, tokens);
break;
case 'users':
await handleUsersCommand(message);
break;
case 'bans':
await handleBansCommand(message);
break;
default:
await message.reply(formatHelp());
break;
+7 -1
View File
@@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const loggerRoot = require('../globals/logger');
const logger = loggerRoot.child('logStream');
const { isBannedSocket } = require('./moderationService');
const MAX_HISTORY = 200;
const history = [];
@@ -14,11 +15,16 @@ function pushEntry(entry) {
}
function broadcast(entry) {
io.emit('log:entry', entry);
io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('log:entry', entry);
}
});
}
function hydrateSocket(socket) {
if (!socket) return;
if (isBannedSocket(socket)) return;
socket.emit('log:init', history);
}
+607
View File
@@ -0,0 +1,607 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const logger = require('../globals/logger').child('moderationService');
const { getRole, roleEvents } = require('./roleService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getSocketIp } = require('../helpers/ipResolver');
const { parseCookieHeader } = require('../helpers/cookieParser');
const { logAdminEvent } = require('./adminLogService');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'moderation.json');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const VISITOR_COOKIE = 'roverd_visitor';
const EVENT_ALLOWLIST = new Set(['auth:login']);
const MAX_HISTORY_ENTRIES = 50;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load moderation store', err.message);
}
cache = { users: {}, bans: {}, history: [] };
}
if (!cache.users) cache.users = {};
if (!cache.bans) cache.bans = {};
if (!Array.isArray(cache.history)) cache.history = [];
return cache;
}
function isAdminRole(role) {
return ADMIN_ROLES.has(role);
}
function isAdminSocket(socket) {
return isAdminRole(getRole(socket));
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function recordHistory(entry) {
const store = loadStore();
store.history.push(entry);
if (store.history.length > MAX_HISTORY_ENTRIES) {
store.history.shift();
}
}
function parseClientId(socket) {
const auth = socket.handshake?.auth || {};
const clientId =
auth.clientId ||
socket.handshake?.query?.clientId ||
socket.data?.clientId ||
null;
if (typeof clientId === 'string' && clientId.trim()) {
return clientId.trim();
}
return null;
}
function parseVisitorToken(socket) {
const cookies = parseCookieHeader(socket.handshake?.headers?.cookie || '');
const token = cookies[VISITOR_COOKIE];
if (typeof token === 'string' && token.trim()) {
return token.trim();
}
return null;
}
function buildIdentity(socket) {
if (!socket) return {};
return {
clientId: parseClientId(socket),
visitorToken: parseVisitorToken(socket),
ip: getSocketIp(socket),
};
}
function findUserByIdentity(identity) {
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (identity.clientId && user.clientId === identity.clientId) return true;
if (identity.visitorToken && user.visitorToken === identity.visitorToken) return true;
if (identity.ip && Array.isArray(user.ips) && user.ips.includes(identity.ip)) return true;
return false;
}) || null;
}
function findUserByQuery(query) {
if (!query) return null;
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (user.id === query) return true;
if (user.clientId === query) return true;
if (user.visitorToken === query) return true;
if (user.lastSocketId === query) return true;
if (Array.isArray(user.socketIds) && user.socketIds.includes(query)) return true;
if (Array.isArray(user.nicknames) && user.nicknames.includes(query)) return true;
if (Array.isArray(user.ips) && user.ips.includes(query)) return true;
return false;
}) || null;
}
function updateUserFromSocket(user, socket, identity) {
let changed = false;
const now = Date.now();
if (!user.firstSeen) {
user.firstSeen = now;
changed = true;
}
if (!user.lastSeen || now > user.lastSeen) {
user.lastSeen = now;
changed = true;
}
const role = getRole(socket);
if (user.lastRole !== role) {
user.lastRole = role;
changed = true;
}
const nickname = getNickname(socket) || null;
if (nickname) {
user.nicknames = Array.isArray(user.nicknames) ? user.nicknames : [];
if (!user.nicknames.includes(nickname)) {
user.nicknames.push(nickname);
changed = true;
}
}
if (identity.clientId && user.clientId !== identity.clientId) {
user.clientId = identity.clientId;
changed = true;
}
if (identity.visitorToken && user.visitorToken !== identity.visitorToken) {
user.visitorToken = identity.visitorToken;
changed = true;
}
if (identity.ip) {
user.ips = Array.isArray(user.ips) ? user.ips : [];
if (!user.ips.includes(identity.ip)) {
user.ips.push(identity.ip);
changed = true;
}
user.lastIp = identity.ip;
}
if (user.lastSocketId !== socket.id) {
user.lastSocketId = socket.id;
changed = true;
}
user.socketIds = Array.isArray(user.socketIds) ? user.socketIds : [];
if (!user.socketIds.includes(socket.id)) {
user.socketIds.push(socket.id);
changed = true;
}
if (ADMIN_ROLES.has(role) && !user.admin) {
user.admin = true;
user.adminName = socket?.data?.user?.username || null;
changed = true;
}
return changed;
}
function ensureUserForSocket(socket) {
const store = loadStore();
const identity = buildIdentity(socket);
let user = findUserByIdentity(identity);
if (!user) {
user = {
id: uuidv4(),
clientId: identity.clientId || null,
visitorToken: identity.visitorToken || null,
ips: identity.ip ? [identity.ip] : [],
nicknames: [],
socketIds: [],
firstSeen: null,
lastSeen: null,
lastRole: null,
lastSocketId: null,
lastIp: identity.ip || null,
admin: false,
adminName: null,
};
store.users[user.id] = user;
}
const changed = updateUserFromSocket(user, socket, identity);
socket.data.moderation = { userId: user.id, ...identity };
if (changed) {
if (user.admin) {
clearBansForUser(user, { reason: 'Admin role applied' });
}
saveStore(store);
emitModerationSnapshot();
}
return user;
}
function cleanupExpiredBans() {
const store = loadStore();
const now = Date.now();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.expiresAt && ban.expiresAt <= now) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
saveStore(store);
}
return changed;
}
function banMatchesIdentity(ban, identity) {
if (!ban) return false;
if (ban.userId && identity.userId && ban.userId === identity.userId) return true;
if (ban.clientId && identity.clientId && ban.clientId === identity.clientId) return true;
if (ban.visitorToken && identity.visitorToken && ban.visitorToken === identity.visitorToken) return true;
if (ban.ip && identity.ip && ban.ip === identity.ip) return true;
return false;
}
function findActiveBan(identity) {
cleanupExpiredBans();
const store = loadStore();
const bans = Object.values(store.bans || {});
return (
bans.find((ban) => {
if (ban.expiresAt && ban.expiresAt <= Date.now()) return false;
return banMatchesIdentity(ban, identity);
}) || null
);
}
function isBannedSocket(socket) {
if (!socket || isAdminSocket(socket)) return false;
const identity = socket.data?.moderation || buildIdentity(socket);
identity.userId = identity.userId || socket.data?.moderation?.userId || null;
return Boolean(findActiveBan(identity));
}
function refreshSocketStatus(socket) {
if (!socket) return;
if (isAdminSocket(socket)) {
if (socket.data?.banInfo) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
}
return;
}
const identity = { ...(socket.data?.moderation || buildIdentity(socket)) };
const ban = findActiveBan(identity);
const prevId = socket.data?.banInfo?.id || null;
if (!ban && prevId) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (!ban) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (prevId !== ban.id) {
socket.data.banInfo = ban;
socket.emit('moderation:status', {
banned: true,
reason: ban.reason || null,
expiresAt: ban.expiresAt || null,
createdAt: ban.createdAt || null,
});
}
}
function serializeUser(user) {
const activeBan = findActiveBan({ userId: user.id, clientId: user.clientId, visitorToken: user.visitorToken, ip: user.lastIp });
return {
id: user.id,
clientId: user.clientId || null,
visitorToken: user.visitorToken || null,
ips: user.ips || [],
nicknames: user.nicknames || [],
lastSeen: user.lastSeen || null,
firstSeen: user.firstSeen || null,
lastRole: user.lastRole || null,
lastSocketId: user.lastSocketId || null,
admin: Boolean(user.admin),
adminName: user.adminName || null,
ban: activeBan
? {
id: activeBan.id,
reason: activeBan.reason || null,
createdAt: activeBan.createdAt || null,
expiresAt: activeBan.expiresAt || null,
createdBy: activeBan.createdBy || null,
}
: null,
};
}
function getModerationSnapshot() {
cleanupExpiredBans();
const store = loadStore();
return {
users: Object.values(store.users || {}).map(serializeUser),
bans: Object.values(store.bans || {}),
};
}
function emitModerationSnapshot() {
const payload = getModerationSnapshot();
io.sockets.sockets.forEach((socket) => {
if (!isAdminSocket(socket)) return;
socket.emit('moderation:update', payload);
});
}
function clearBansForUser(user, meta = {}) {
if (!user) return false;
const store = loadStore();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.userId === user.id) {
delete store.bans[ban.id];
changed = true;
}
if (user.clientId && ban.clientId === user.clientId) {
delete store.bans[ban.id];
changed = true;
}
if (user.visitorToken && ban.visitorToken === user.visitorToken) {
delete store.bans[ban.id];
changed = true;
}
if (user.lastIp && ban.ip === user.lastIp) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
recordHistory({
id: uuidv4(),
action: 'unban',
createdAt: Date.now(),
createdBy: meta.by || null,
reason: meta.reason || null,
userId: user.id,
});
saveStore(store);
}
return changed;
}
function resolveTarget(target = {}) {
if (typeof target === 'string') {
const query = target.trim();
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(query)) {
return { ip: query, query };
}
return { query };
}
return target;
}
function createBan(target, { durationMs = null, reason = null, createdBy = null } = {}) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
if (user && user.admin) {
throw new Error('Admins cannot be banned.');
}
if (resolved.ip) {
const adminMatch = Object.values(store.users || {}).some(
(entry) => entry.admin && Array.isArray(entry.ips) && entry.ips.includes(resolved.ip),
);
if (adminMatch) {
throw new Error('Admins cannot be banned.');
}
}
const identity = {
userId: user?.id || null,
clientId: resolved.clientId || user?.clientId || null,
visitorToken: resolved.visitorToken || user?.visitorToken || null,
ip: resolved.ip || user?.lastIp || null,
};
if (!identity.userId && !identity.clientId && !identity.visitorToken && !identity.ip) {
throw new Error('Unknown user.');
}
const cleanReason = typeof reason === 'string' ? reason.trim() : null;
const safeDuration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : null;
const ban = {
id: uuidv4(),
userId: identity.userId,
clientId: identity.clientId,
visitorToken: identity.visitorToken,
ip: identity.ip,
reason: cleanReason || null,
createdAt: Date.now(),
expiresAt: safeDuration ? Date.now() + safeDuration : null,
createdBy: createdBy || null,
};
Object.values(store.bans || {}).forEach((existing) => {
if (banMatchesIdentity(existing, identity)) {
delete store.bans[existing.id];
}
});
store.bans[ban.id] = ban;
recordHistory({
id: uuidv4(),
action: durationMs ? 'timeout' : 'ban',
createdAt: ban.createdAt,
createdBy: ban.createdBy,
reason: ban.reason,
userId: ban.userId || null,
banId: ban.id,
expiresAt: ban.expiresAt,
});
saveStore(store);
return ban;
}
function removeBan(target) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const banId = resolved.banId || resolved.query;
if (banId && store.bans[banId]) {
delete store.bans[banId];
saveStore(store);
return true;
}
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
if (user) {
const changed = clearBansForUser(user, { by: resolved.by || null, reason: resolved.reason || null });
if (changed) {
saveStore(store);
}
return changed;
}
if (resolved.ip) {
let removed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.ip === resolved.ip) {
delete store.bans[ban.id];
removed = true;
}
});
if (removed) {
saveStore(store);
}
return removed;
}
return false;
}
function applyBan(target, options) {
const ban = createBan(target, options);
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
return ban;
}
function applyUnban(target) {
const removed = removeBan(target);
if (removed) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
return removed;
}
function registerSocket(socket) {
const user = ensureUserForSocket(socket);
refreshSocketStatus(socket);
socket.use((packet, next) => {
if (!packet || !packet.length) return next();
const event = packet[0];
if (EVENT_ALLOWLIST.has(event)) return next();
if (isAdminSocket(socket)) return next();
if (isBannedSocket(socket)) {
return next(new Error('banned'));
}
return next();
});
socket.on('disconnect', () => {
const store = loadStore();
if (!store.users[user.id]) return;
store.users[user.id].lastSeen = Date.now();
saveStore(store);
});
}
io.on('connection', (socket) => {
registerSocket(socket);
if (isAdminSocket(socket)) {
socket.emit('moderation:init', getModerationSnapshot());
}
socket.on('moderation:ban', ({ target, durationMs, reason } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const ban = applyBan(target, {
durationMs: durationMs || null,
reason,
createdBy: socket?.data?.user?.username || socket.id,
});
logAdminEvent({
label: 'moderation',
message: durationMs ? 'User timed out' : 'User banned',
ip: socket?.data?.moderation?.ip || null,
meta: { target, reason, expiresAt: ban.expiresAt || null },
socketId: socket.id,
});
cb({ success: true, ban });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('moderation:unban', ({ target } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const removed = applyUnban({ ...target, by: socket?.data?.user?.username || socket.id });
if (removed) {
logAdminEvent({
label: 'moderation',
message: 'User unbanned',
ip: socket?.data?.moderation?.ip || null,
meta: { target },
socketId: socket.id,
});
}
cb({ success: true, removed });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket, role }) => {
if (!socket) return;
if (ADMIN_ROLES.has(role)) {
const user = ensureUserForSocket(socket);
if (user.admin) {
clearBansForUser(user, { reason: 'Admin role applied', by: socket?.data?.user?.username || socket.id });
emitModerationSnapshot();
refreshSocketStatus(socket);
}
socket.emit('moderation:init', getModerationSnapshot());
}
});
nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) {
const store = loadStore();
const identity = buildIdentity(socket);
const user = findUserByIdentity(identity);
if (user) {
const changed = updateUserFromSocket(user, socket, identity);
if (changed) {
saveStore(store);
emitModerationSnapshot();
}
}
}
});
setInterval(() => {
const expired = cleanupExpiredBans();
if (expired) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
}, 30 * 1000);
module.exports = {
buildIdentity,
getModerationSnapshot,
isBannedSocket,
findUserByQuery,
createBan,
removeBan,
applyBan,
applyUnban,
refreshSocketStatus,
};
+6 -3
View File
@@ -6,6 +6,7 @@ const ALERT_COLOR = '#8bc34a';
const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager');
const { isAdmin, roleEvents } = require('./roleService');
const { isBannedSocket } = require('./moderationService');
const { publishEvent } = require('./eventBus');
const videoSessions = require('./videoSessions');
@@ -576,9 +577,11 @@ roleEvents.on('change', ({ socket, role }) => {
});
io.on('connection', (socket) => {
socket.emit('rovers', getRoster());
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
if (!isBannedSocket(socket)) {
socket.emit('rovers', getRoster());
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
}
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
+2
View File
@@ -15,6 +15,7 @@ const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { subscribe } = require('./eventBus');
const { isBannedSocket } = require('./moderationService');
const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
@@ -68,6 +69,7 @@ function buildSession(socket) {
function syncSocket(socket) {
if (!socket) return;
if (isBannedSocket(socket)) return;
const payload = buildSession(socket);
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
socket.emit('session:sync', payload);
+4
View File
@@ -8,6 +8,7 @@ const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader');
const { getRequestIp } = require('../helpers/ipResolver');
const { logAdminEvent } = require('./adminLogService');
const { isBannedSocket } = require('./moderationService');
const config = loadConfig();
const mediaConfig = config.media || {};
@@ -110,6 +111,9 @@ app.post('/mediamtx/auth', (req, res) => {
videoSessions.revokeSession(sessionId);
return res.status(401).end();
}
if (isBannedSocket(socket)) {
return res.status(401).end();
}
if (!canView(socket)) {
return res.status(401).end();
}
@@ -5,6 +5,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader');
const { isBannedSocket } = require('./moderationService');
const config = loadConfig();
const mediaConfig = config.media || {};
@@ -80,6 +81,9 @@ function normalizeRequest(payload = {}) {
io.on('connection', (socket) => {
socket.on('video:request', (payload = {}, cb = () => {}) => {
try {
if (isBannedSocket(socket)) {
throw new Error('Banned');
}
const target = normalizeRequest(payload);
if (!target) {
throw new Error('video source required');