mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ban system
This commit is contained in:
@@ -28,6 +28,7 @@ require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/moderationService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/replaySegmentManager');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CGvoGgz3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-GYsISCRb.css">
|
||||
<script type="module" crossorigin src="/assets/index-BFooYURt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-f8xLbmgU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const config = require('./config');
|
||||
const { parseCookieHeader } = require('../helpers/cookieParser');
|
||||
|
||||
const VISITOR_COOKIE = 'roverd_visitor';
|
||||
const VISITOR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
const cookies = parseCookieHeader(req.headers?.cookie || '');
|
||||
let token = cookies[VISITOR_COOKIE];
|
||||
if (!token) {
|
||||
token = uuidv4();
|
||||
const cookie = `${VISITOR_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${VISITOR_COOKIE_MAX_AGE}`;
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
req.visitorToken = token;
|
||||
next();
|
||||
});
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function parseCookieHeader(header = '') {
|
||||
if (!header || typeof header !== 'string') return {};
|
||||
return header
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((acc, part) => {
|
||||
const [key, ...rest] = part.split('=');
|
||||
if (!key) return acc;
|
||||
acc[key] = rest.join('=');
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseCookieHeader,
|
||||
};
|
||||
@@ -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