Compare commits

...
2 Commits
Author SHA1 Message Date
legop3 b19bec7ee5 no persist admin status 2026-01-23 04:18:57 -05:00
legop3 987ef29f30 ban system 2026-01-23 04:12:38 -05:00
23 changed files with 1240 additions and 133 deletions
+1
View File
@@ -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
+2 -2
View File
@@ -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-DECQ2TrX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-f8xLbmgU.css">
</head>
<body>
<div id="root"></div>
+16
View File
@@ -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);
+17
View File
@@ -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,
};
+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);
}
+587
View File
@@ -0,0 +1,587 @@
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;
}
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,
};
store.users[user.id] = user;
}
const changed = updateUserFromSocket(user, socket, identity);
socket.data.moderation = { userId: user.id, ...identity };
if (changed) {
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,
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);
const targetSocketId = resolved.socketId || user?.lastSocketId || null;
if (targetSocketId) {
const targetSocket = io.sockets.sockets.get(targetSocketId);
if (targetSocket && isAdminSocket(targetSocket)) {
throw new Error('Active 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)) {
ensureUserForSocket(socket);
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');
+2
View File
@@ -27,6 +27,7 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
import BannedOverlay from './components/BannedOverlay.jsx';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -235,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
<BannedOverlay />
<HelpOverlay
visible={helpVisible}
layout={layout}
+204 -1
View File
@@ -10,7 +10,18 @@ const MODES = [
];
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl, setCommunityGoal, adminLogs } = useSession();
const {
session,
lockRover,
setMode,
requestControl,
setCommunityGoal,
adminLogs,
moderation,
banUser,
timeoutUser,
unbanUser,
} = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const health = session?.health || null;
@@ -135,6 +146,12 @@ export default function AdminPanel() {
)}
/>
<ReplaySnapshotHealth health={health} />
<ModerationPanel
moderation={moderation}
onBan={banUser}
onTimeout={timeoutUser}
onUnban={unbanUser}
/>
<AdminIpLogPanel entries={adminLogs} />
</section>
);
@@ -202,6 +219,192 @@ function ReplaySnapshotHealth({ health }) {
);
}
function ModerationPanel({ moderation, onBan, onTimeout, onUnban }) {
const [filter, setFilter] = useState('');
const [timeoutMinutes, setTimeoutMinutes] = useState(30);
const [reason, setReason] = useState('');
const users = moderation?.users || [];
const normalized = filter.trim().toLowerCase();
const filtered = normalized
? users.filter((user) => {
const label = user.nicknames?.[user.nicknames.length - 1] || user.id || '';
return (
label.toLowerCase().includes(normalized) ||
String(user.id).toLowerCase().includes(normalized) ||
String(user.lastSocketId || '').toLowerCase().includes(normalized)
);
})
: users;
const handleBan = async (user) => {
try {
await onBan({ userId: user.id }, reason.trim() || null);
} catch (err) {
alert(err.message);
}
};
const handleTimeout = async (user) => {
const durationMs = Math.max(1, Number(timeoutMinutes) || 0) * 60 * 1000;
try {
await onTimeout({ userId: user.id }, durationMs, reason.trim() || null);
} catch (err) {
alert(err.message);
}
};
const handleUnban = async (user) => {
try {
await onUnban({ userId: user.id });
} catch (err) {
alert(err.message);
}
};
return (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="panel-muted text-xs uppercase">Moderation</span>
<span className="text-slate-500">{filtered.length} users</span>
</div>
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="flex flex-wrap items-center gap-0.5">
<input
className="field-input flex-1 min-w-[10rem] text-xs"
placeholder="Search users"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<input
className="field-input flex-1 min-w-[12rem] text-xs"
placeholder="Reason (optional)"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
<div className="flex items-center gap-0.25 text-xs text-slate-400">
<span>Timeout (min)</span>
<input
className="field-input w-16 text-xs"
type="number"
min="1"
value={timeoutMinutes}
onChange={(event) => setTimeoutMinutes(event.target.value)}
/>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-xs text-slate-500">No users tracked yet.</p>
) : (
filtered
.slice()
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
.map((user) => {
const label = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
const ban = user.ban || null;
return (
<div key={user.id} className="surface-muted space-y-0.25 text-xs">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<div className="flex flex-wrap items-center gap-0.5">
<span className="text-slate-100">{label}</span>
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
{user.id.slice(0, 6)}
</span>
{ban && (
<span className="rounded bg-red-500/30 px-1 text-[0.7rem] text-red-200">
{ban.expiresAt ? 'Timeout' : 'Banned'}
</span>
)}
</div>
<div className="flex flex-wrap gap-0.25">
<button
type="button"
className="button-dark text-[0.7rem]"
onClick={() => handleBan(user)}
>
Ban
</button>
<button
type="button"
className="button-dark text-[0.7rem]"
onClick={() => handleTimeout(user)}
>
Timeout
</button>
<button
type="button"
className="button-danger text-[0.7rem]"
onClick={() => handleUnban(user)}
disabled={!ban}
>
Unban
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-0.5 text-[0.7rem] text-slate-400">
{user.lastSocketId && <span>socket {user.lastSocketId.slice(0, 6)}</span>}
{user.lastSeen && <span>last seen {new Date(user.lastSeen).toLocaleString()}</span>}
{ban?.expiresAt && (
<span>expires {new Date(ban.expiresAt).toLocaleString()}</span>
)}
{user.ips?.length ? (
<span>ips {user.ips.slice(-3).join(', ')}</span>
) : null}
</div>
</div>
);
})
)}
</div>
<ModerationBanList bans={moderation?.bans || []} onUnban={onUnban} />
</div>
);
}
function ModerationBanList({ bans, onUnban }) {
if (!bans.length) {
return (
<div className="surface text-xs text-slate-500">
No active bans/timeouts.
</div>
);
}
return (
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="panel-muted text-xs uppercase text-slate-400">Active bans</div>
{bans.map((ban) => (
<div key={ban.id} className="flex flex-wrap items-center justify-between gap-0.5">
<div className="flex flex-wrap items-center gap-0.5">
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
{ban.id.slice(0, 6)}
</span>
{ban.userId && (
<span className="text-[0.7rem] text-slate-400">user {ban.userId.slice(0, 6)}</span>
)}
<span className="text-[0.7rem] text-slate-300">
{ban.expiresAt ? 'timeout' : 'ban'}
</span>
{ban.expiresAt && (
<span className="text-[0.7rem] text-slate-400">
until {new Date(ban.expiresAt).toLocaleString()}
</span>
)}
{ban.reason ? (
<span className="text-[0.7rem] text-slate-400">reason {ban.reason}</span>
) : null}
</div>
<button
type="button"
className="button-danger text-[0.7rem]"
onClick={() => onUnban({ banId: ban.id })}
>
Unban
</button>
</div>
))}
</div>
);
}
function AdminIpLogPanel({ entries }) {
const logs = entries || [];
return (
+43
View File
@@ -0,0 +1,43 @@
import AuthPanel from './AuthPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
function formatExpiry(expiresAt) {
if (!expiresAt) return 'permanent';
const ms = expiresAt - Date.now();
if (ms <= 0) return 'expiring soon';
const minutes = Math.ceil(ms / 60000);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
const hours = Math.ceil(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
const days = Math.ceil(hours / 24);
return `${days} day${days === 1 ? '' : 's'}`;
}
export default function BannedOverlay() {
const { banStatus } = useSession();
if (!banStatus?.banned) return null;
const expiresAt = banStatus.expiresAt || null;
const reason = banStatus.reason || null;
return (
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-0.5 py-0.5">
<div className="surface w-full max-w-xl space-y-0.5 text-slate-100 shadow-2xl">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-red-300">Access blocked</p>
<p className="text-sm text-slate-300">
Your access has been {expiresAt ? 'temporarily restricted' : 'banned'}.
</p>
<div className="text-xs text-slate-400">
{expiresAt ? `Timeout ends in ${formatExpiry(expiresAt)}.` : 'This ban has no expiration.'}
</div>
{reason && <div className="text-xs text-slate-400">Reason: {reason}</div>}
</div>
<div className="surface-muted">
<AuthPanel />
</div>
<p className="text-xs text-slate-500">
Admins can log in above to regain access.
</p>
</div>
</div>
);
}
+35 -1
View File
@@ -8,6 +8,8 @@ const SessionContext = createContext({
session: null,
logs: [],
adminLogs: [],
banStatus: null,
moderation: null,
login: async () => {},
setRole: async () => {},
requestControl: async () => {},
@@ -18,6 +20,9 @@ const SessionContext = createContext({
setNickname: async () => {},
triggerReplay: async () => {},
setCommunityGoal: async () => {},
banUser: async () => {},
timeoutUser: async () => {},
unbanUser: async () => {},
});
function useAckEmitter(socket) {
@@ -42,6 +47,8 @@ export function SessionProvider({ children }) {
const [session, setSession] = useState(null);
const [logs, setLogs] = useState([]);
const [adminLogs, setAdminLogs] = useState([]);
const [banStatus, setBanStatus] = useState(null);
const [moderation, setModeration] = useState(null);
const [alerts, setAlerts] = useState([]);
const [connected, setConnected] = useState(socket.connected);
@@ -72,11 +79,29 @@ export function SessionProvider({ children }) {
function handleAdminLogEntry(entry) {
setAdminLogs((prev) => [...prev.slice(-199), entry]);
}
function handleModerationStatus(payload = {}) {
const banned = Boolean(payload.banned);
setBanStatus(banned ? payload : null);
if (banned) {
setSession(null);
setLogs([]);
setAlerts([]);
}
}
function handleModerationInit(payload = {}) {
setModeration(payload);
}
function handleModerationUpdate(payload = {}) {
setModeration(payload);
}
socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
socket.on('adminlog:init', handleAdminLogInit);
socket.on('adminlog:entry', handleAdminLogEntry);
socket.on('moderation:status', handleModerationStatus);
socket.on('moderation:init', handleModerationInit);
socket.on('moderation:update', handleModerationUpdate);
socket.on('alert:new', (payload = {}) => {
setAlerts((prev) => [
...prev.slice(-49),
@@ -92,6 +117,9 @@ export function SessionProvider({ children }) {
socket.off('log:entry', handleLogEntry);
socket.off('adminlog:init', handleAdminLogInit);
socket.off('adminlog:entry', handleAdminLogEntry);
socket.off('moderation:status', handleModerationStatus);
socket.off('moderation:init', handleModerationInit);
socket.off('moderation:update', handleModerationUpdate);
socket.off('alert:new');
};
}, [socket]);
@@ -112,6 +140,10 @@ export function SessionProvider({ children }) {
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
banUser: (target, reason) => emitWithAck('moderation:ban', { target, reason }),
timeoutUser: (target, durationMs, reason) =>
emitWithAck('moderation:ban', { target, durationMs, reason }),
unbanUser: (target) => emitWithAck('moderation:unban', { target }),
pushAlert: (alert) =>
setAlerts((prev) => [
...prev.slice(-49),
@@ -127,10 +159,12 @@ export function SessionProvider({ children }) {
session,
logs,
adminLogs,
banStatus,
moderation,
alerts,
...actions,
}),
[actions, adminLogs, alerts, connected, logs, session],
[actions, adminLogs, alerts, banStatus, connected, logs, moderation, session],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
+19
View File
@@ -7,8 +7,27 @@ console.info('[socket] connecting to', resolvedUrl);
const settings = loadSettings();
const transportPref = settings?.page?.connectionTransport || 'websocket';
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
const CLIENT_ID_KEY = 'roverd_client_id';
function getClientId() {
if (typeof window === 'undefined') return null;
try {
const existing = window.localStorage.getItem(CLIENT_ID_KEY);
if (existing) return existing;
const created = (crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(CLIENT_ID_KEY, created);
return created;
} catch (err) {
return null;
}
}
const clientId = getClientId();
export const socket = io(resolvedUrl, {
transports,
timeout: 15000,
auth: {
clientId,
},
});
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
+2
View File
@@ -9,6 +9,7 @@ import VideoTile from '../components/VideoTile.jsx';
import ChatPanel from '../components/ChatPanel.jsx';
import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import BannedOverlay from '../components/BannedOverlay.jsx';
const ROTATE_MS = 20000;
const HARD_REFRESH_MS = 3 * 60 * 60 * 1000;
@@ -153,6 +154,7 @@ export default function MiniSummaryApp() {
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
<BannedOverlay />
</>
</SettingsProvider>
);
+5 -1
View File
@@ -13,6 +13,7 @@ import useDefaultNickname from '../hooks/useDefaultNickname.js';
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
import BannedOverlay from '../components/BannedOverlay.jsx';
function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
@@ -159,7 +160,10 @@ function SpectatorContent() {
export default function SpectatorApp() {
return (
<SettingsProvider>
<SpectatorContent />
<>
<SpectatorContent />
<BannedOverlay />
</>
</SettingsProvider>
);
}