mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
ban system
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 = () => {}) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user