openvibers/openvibe.live Commits

Bans never sweep up staff; chat's one-click global ban gets its confirmation back

OP OpenVibers committed 16 Sept 2026 · 69c003a0c7 · 6 files, +206 −1056

**A site ban could ban the site's admins.** Both global-ban routes cascade to "every account that
has ever used this IP", via getLinkedAccounts(), which returns accounts of any role. The IP-wide
ban (banAllAccountsOnIp) did the same and also banned the moderator issuing it. On production all
three admin accounts share an IP with some other account — staff browse from the same home and
phone networks as people who have been banned — and the admin exemption from IP bans only covers
sessions that are not themselves banned. So banning one of those other accounts would have set
is_banned on an admin and locked them out of their own site.

- /global-ban and /users/:id/ban were two copies of the same code. They are now one function,
  performGlobalBan, so a fix to one cannot miss the other again.
- The cascade and the IP-wide ban skip admins, global mods and the moderator doing the banning,
  and log how many staff accounts on the IP were left alone.
- A moderator cannot directly ban staff of equal or higher rank, and nobody can ban themselves
  (the admin router's single-user ban refuses self-bans too).

**Chat's "Global ban" banned on a single click.** It preferred staffBanUser() from
staff-console.js when present — and staffBanUser has no confirmation step. The staff console's UI
was removed earlier; the script was still loaded on every page (48KB) for nothing else. It is
gone, so chat and the channel page both use the confirmed /mod/global-ban path. Nothing else
referenced it.

test/ban-cascade.test.js runs the real ban SQL against an in-memory SQLite database: an admin and
a moderator sharing IPs with a troll and their alt. Confirmed failing with either exemption
removed.

Also: /api/home/hero now reports a Server-Timing breakdown. Measured after a restart, the first
request built the payload in 33ms — the ~2.3s seen earlier was requests queued during the restart,
not the handler.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 4aa63f67..21e1bb72 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5206,7 +5206,6 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
--<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=3" defer></script>
<script src="/js/recap.js?v=1" defer></script>
ddiff --git a/public/js/staff-console.js b/public/js/staff-console.js
ddeleted file mode 100644
iindex 13ac3988..00000000
---- a/public/js/staff-console.js
++++ /dev/null
@@@ -1,944 +0,0 @@
--const STAFF_TAB_DEFS = [
-- { id: 'overview', label: 'Overview', icon: 'fa-chart-pie' },
-- { id: 'chat', label: 'Chat Logs', icon: 'fa-comments' },
-- { id: 'streams', label: 'Streams', icon: 'fa-video' },
-- { id: 'bans', label: 'Bans', icon: 'fa-ban' },
-- { id: 'actions', label: 'Audit Log', icon: 'fa-list-check' },
-- { id: 'canvas', label: 'Canvas', icon: 'fa-brush' },
-- { id: 'users', label: 'Users', icon: 'fa-users', adminOnly: true },
-- { id: 'global-mods', label: 'Global Mods', icon: 'fa-shield-halved', adminOnly: true },
-- { id: 'settings', label: 'Settings', icon: 'fa-sliders', adminOnly: true },
-- { id: 'verification', label: 'Verification', icon: 'fa-key', adminOnly: true },
-- { id: 'cashouts', label: 'Cashouts', icon: 'fa-money-bill-transfer', adminOnly: true },
-- { id: 'vpn', label: 'VPN Queue', icon: 'fa-network-wired', adminOnly: true },
--];
--
--const LEGACY_ADMIN_TAB_LOADERS = {
-- users: () => typeof loadAdminUsers === 'function' && loadAdminUsers(),
-- 'global-mods': () => typeof loadAdminModerators === 'function' && loadAdminModerators(),
-- settings: () => typeof loadAdminSettings === 'function' && loadAdminSettings(),
-- verification: () => typeof loadAdminVerificationKeys === 'function' && loadAdminVerificationKeys(),
-- cashouts: () => typeof loadAdminCashouts === 'function' && loadAdminCashouts(),
-- vpn: () => typeof loadAdminVPN === 'function' && loadAdminVPN(),
--};
--
--let staffLogsOffset = 0;
--let staffLogsQuery = '';
--let staffLogsUserId = '';
--let staffCanvasCache = null;
--
--function getStaffCaps() {
-- return getUserCapabilities(currentUser);
--}
--
--function isAdminStaffUser() {
-- return hasCapability('can_manage_users');
--}
--
--function getStaffRoleLabel() {
-- const role = getStaffCaps().staff_role;
-- if (role === 'admin') return 'Administrator';
-- if (role === 'global_mod') return 'Global Moderator';
-- return 'Staff';
--}
--
--function getAllowedStaffTabs() {
-- return STAFF_TAB_DEFS.filter((tab) => !tab.adminOnly || isAdminStaffUser());
--}
--
--function normalizeStaffTab(tab) {
-- const aliases = {
-- moderators: 'global-mods',
-- 'chat-logs': 'chat',
-- };
-- return aliases[tab] || tab;
--}
--
--function getDefaultStaffTab() {
-- const tabs = getAllowedStaffTabs();
-- return tabs[0]?.id || 'overview';
--}
--
--function ensureAllowedStaffTab(tab) {
-- const normalized = normalizeStaffTab(tab);
-- return getAllowedStaffTabs().some((item) => item.id === normalized) ? normalized : getDefaultStaffTab();
--}
--
--function formatStaffDate(value) {
-- if (!value) return '-';
-- const normalized = value.includes('T') || value.endsWith('Z')
-- ? value
-- : value.replace(' ', 'T') + 'Z';
-- const date = new Date(normalized);
-- return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
--}
--
--function summarizeModAction(action) {
-- const details = action.details || {};
-- const pieces = [];
-- if (details.reason) pieces.push(esc(details.reason));
-- if (details.username) pieces.push(`user ${esc(details.username)}`);
-- if (details.count) pieces.push(`${details.count} tiles`);
-- if (details.stream_id) pieces.push(`stream #${details.stream_id}`);
-- if (details.channel_id) pieces.push(`channel #${details.channel_id}`);
-- return pieces.join(' - ') || 'No details';
--}
--
--function toIsoFromInput(value) {
-- return value ? new Date(value).toISOString() : null;
--}
--
--async function resolveUsernameToUserId(username) {
-- const profile = await api(`/chat/user/${encodeURIComponent(username)}/profile`);
-- return profile?.id || null;
--}
--
--function renderStaffTabs() {
-- const tabsContainer = document.querySelector('#page-admin .admin-tabs');
-- if (!tabsContainer) return;
-- tabsContainer.innerHTML = getAllowedStaffTabs().map((tab) => `
-- <button class="tab-btn ${currentAdminTab === tab.id ? 'active' : ''}" onclick="switchAdminTab('${tab.id}')">
-- <i class="fa-solid ${tab.icon}"></i> ${tab.label}
-- </button>
-- `).join('');
--}
--
--async function loadStaffStats() {
-- const container = document.getElementById('admin-stats');
-- if (!container) return;
-- container.innerHTML = '<p class="muted">Loading staff stats...</p>';
--
-- try {
-- const [modData, adminData] = await Promise.all([
-- api('/mod/stats'),
-- isAdminStaffUser() ? api('/admin/stats') : Promise.resolve(null),
-- ]);
--
-- const modStats = modData?.stats || {};
-- const adminStats = adminData || {};
-- const cards = [];
--
-- if (isAdminStaffUser()) {
-- cards.push(
-- { label: 'Total Users', value: adminStats.users?.total || 0, icon: 'fa-users' },
-- { label: 'Live Streams', value: adminStats.streams?.live || 0, icon: 'fa-broadcast-tower' },
-- { label: 'Pending Cashouts', value: adminStats.openvibeBucks?.pendingCashouts || 0, icon: 'fa-money-bill-transfer' },
-- { label: 'Active Bans', value: modStats.activeBans || 0, icon: 'fa-ban' },
-- { label: 'Chat Messages', value: modStats.totalMessages || 0, icon: 'fa-comments' },
-- { label: 'Channel Actions', value: modStats.channelActions || 0, icon: 'fa-gavel' }
-- );
-- } else {
-- cards.push(
-- { label: 'Live Streams', value: modStats.liveStreams || 0, icon: 'fa-broadcast-tower' },
-- { label: 'Active Bans', value: modStats.activeBans || 0, icon: 'fa-ban' },
-- { label: 'Chat Messages', value: modStats.totalMessages || 0, icon: 'fa-comments' },
-- { label: 'Channel Actions', value: modStats.channelActions || 0, icon: 'fa-gavel' }
-- );
-- }
--
-- cards.unshift({
-- label: 'Your Role',
-- value: getStaffRoleLabel(),
-- icon: 'fa-id-badge',
-- });
--
-- container.innerHTML = cards.map((stat) => `
-- <div class="admin-stat">
-- <div class="admin-stat-value">${typeof stat.value === 'number' ? stat.value.toLocaleString() : esc(stat.value)}</div>
-- <div class="admin-stat-label"><i class="fa-solid ${stat.icon}"></i> ${stat.label}</div>
-- </div>
-- `).join('');
-- } catch (err) {
-- container.innerHTML = '<p class="muted">Failed to load staff stats.</p>';
-- }
--}
--
--async function loadStaffOverview() {
-- const content = document.getElementById('admin-content');
-- const caps = getStaffCaps();
-- const items = [
-- caps.can_moderate_site_chat && 'Search cross-site chat logs and inspect user history.',
-- caps.can_manage_canvas && 'Moderate the collaborative canvas with rollbacks, locks, bans, and snapshots.',
-- caps.can_manage_users && 'Manage users, global moderators, verification keys, cashouts, and site settings.',
-- caps.can_manage_channels && 'Channel moderation lives in the streamer dashboard for owners and channel mods.',
-- ].filter(Boolean);
--
-- content.innerHTML = `
-- <div class="staff-panel-grid">
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-handshake-angle"></i> Staff Access</h3>
-- <p class="muted">Signed in as <strong>${esc(getStaffRoleLabel())}</strong>.</p>
-- <div class="staff-chip-row">
-- <span class="staff-chip ${caps.can_manage_canvas ? 'is-live' : ''}">Canvas Tools</span>
-- <span class="staff-chip ${caps.can_moderate_site_chat ? 'is-live' : ''}">Site Chat</span>
-- <span class="staff-chip ${caps.can_manage_users ? 'is-live' : ''}">Admin Controls</span>
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-route"></i> What Lives Where</h3>
-- <div class="staff-note-list">
-- ${items.map((item) => `<div class="staff-note"><i class="fa-solid fa-check"></i> ${item}</div>`).join('')}
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-brush"></i> Canvas Policy</h3>
-- <div class="staff-note-list">
-- <div class="staff-note"><i class="fa-solid fa-lock"></i> Logged-in users place tiles.</div>
-- <div class="staff-note"><i class="fa-solid fa-hourglass-half"></i> Cooldowns scale with OpenVibeGame total level.</div>
-- <div class="staff-note"><i class="fa-solid fa-shield-halved"></i> Region locks, read-only mode, rollbacks, and user overrides are live staff tools.</div>
-- </div>
-- </div>
-- </div>
-- `;
--}
--
--async function loadStaffChatLogs() {
-- const content = document.getElementById('admin-content');
-- content.innerHTML = `
-- <div class="staff-toolbar">
-- <input type="text" id="staff-log-search" class="form-input" placeholder="Search messages..." value="${esc(staffLogsQuery)}" onkeydown="if(event.key==='Enter')staffSearchLogs()">
-- <input type="text" id="staff-log-userid" class="form-input staff-toolbar-small" placeholder="User ID (optional)" value="${esc(staffLogsUserId)}" onkeydown="if(event.key==='Enter')staffSearchLogs()">
-- <button class="btn btn-primary" onclick="staffSearchLogs()"><i class="fa-solid fa-search"></i> Search</button>
-- </div>
-- <div id="staff-logs-results"><p class="muted">Search site-wide chat logs by message text or user ID.</p></div>
-- <div id="staff-logs-pager" class="staff-pager"></div>
-- `;
--}
--
--async function fetchStaffLogs() {
-- const results = document.getElementById('staff-logs-results');
-- const pager = document.getElementById('staff-logs-pager');
-- if (!results) return;
-- results.innerHTML = '<p class="muted">Loading logs...</p>';
--
-- try {
-- const params = new URLSearchParams({ limit: '50', offset: String(staffLogsOffset) });
-- if (staffLogsQuery) params.set('q', staffLogsQuery);
-- if (staffLogsUserId) params.set('user_id', staffLogsUserId);
-- const data = await api(`/mod/chat/search?${params}`);
-- const messages = data.messages || [];
-- const total = data.total || 0;
--
-- if (!messages.length) {
-- results.innerHTML = '<p class="muted">No messages matched that search.</p>';
-- if (pager) pager.innerHTML = '';
-- return;
-- }
--
-- results.innerHTML = `
-- <table class="admin-table">
-- <thead>
-- <tr><th>Time</th><th>User</th><th>Message</th><th>Stream</th></tr>
-- </thead>
-- <tbody>
-- ${messages.map((message) => `
-- <tr>
-- <td>${formatStaffDate(message.timestamp)}</td>
-- <td>
-- <button class="staff-link-btn" onclick="staffOpenLogsForUser(${message.user_id || 0}, '${esc(message.display_name || message.username || 'Anonymous')}')">
-- ${esc(message.display_name || message.username || 'Anonymous')}
-- </button>
-- </td>
-- <td>${esc(message.message || '')}</td>
-- <td>${message.stream_id || '-'}</td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>
-- `;
--
-- const pages = Math.ceil(total / 50);
-- const currentPageNumber = Math.floor(staffLogsOffset / 50) + 1;
-- pager.innerHTML = pages > 1 ? `
-- <button class="btn btn-small" ${staffLogsOffset <= 0 ? 'disabled' : ''} onclick="staffLogsPrev()"><i class="fa-solid fa-chevron-left"></i></button>
-- <span class="muted">Page ${currentPageNumber} of ${pages} (${total} results)</span>
-- <button class="btn btn-small" ${currentPageNumber >= pages ? 'disabled' : ''} onclick="staffLogsNext()"><i class="fa-solid fa-chevron-right"></i></button>
-- ` : `<span class="muted">${total} results</span>`;
-- } catch (err) {
-- results.innerHTML = `<p class="muted">Failed to load logs: ${esc(err.message || 'Unknown error')}</p>`;
-- if (pager) pager.innerHTML = '';
-- }
--}
--
--async function loadStaffStreams() {
-- const content = document.getElementById('admin-content');
-- content.innerHTML = '<p class="muted">Loading live streams...</p>';
--
-- try {
-- const data = await api('/mod/streams');
-- const streams = data.streams || [];
-- if (!streams.length) {
-- content.innerHTML = '<p class="muted">No live streams right now.</p>';
-- return;
-- }
--
-- content.innerHTML = `
-- <table class="admin-table">
-- <thead>
-- <tr><th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th></tr>
-- </thead>
-- <tbody>
-- ${streams.map((stream) => `
-- <tr>
-- <td>${esc(stream.title || 'Untitled')}</td>
-- <td>${esc(stream.display_name || stream.username || '-')}</td>
-- <td>${esc((stream.protocol || 'webrtc').toUpperCase())}</td>
-- <td>${Number(stream.viewer_count || 0).toLocaleString()}</td>
-- <td>${formatStaffDate(stream.started_at)}</td>
-- <td>
-- <button class="btn btn-small btn-danger" onclick="staffForceEndStream('${stream.id}')">
-- <i class="fa-solid fa-stop"></i> End
-- </button>
-- </td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>
-- `;
-- } catch (err) {
-- content.innerHTML = `<p class="muted">Failed to load streams: ${esc(err.message || 'Unknown error')}</p>`;
-- }
--}
--
--async function loadStaffBans() {
-- const content = document.getElementById('admin-content');
-- content.innerHTML = '<p class="muted">Loading bans...</p>';
--
-- try {
-- const data = await api('/mod/bans');
-- const bans = data.bans || [];
-- content.innerHTML = `
-- <div class="staff-panel-grid">
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-user-slash"></i> Create Site Ban</h3>
-- <div class="staff-form-grid">
-- <input type="text" id="staff-ban-username" class="form-input" placeholder="Username">
-- <input type="number" id="staff-ban-duration" class="form-input" placeholder="Duration hours (0 = permanent)" min="0" step="1">
-- <textarea id="staff-ban-reason" class="form-input staff-form-wide" rows="3" placeholder="Reason"></textarea>
-- </div>
-- <button class="btn btn-primary" onclick="staffCreateSiteBan()"><i class="fa-solid fa-ban"></i> Ban User</button>
-- </div>
-- <div class="staff-panel-card staff-panel-card-wide">
-- <h3><i class="fa-solid fa-list"></i> Active Bans</h3>
-- ${bans.length ? `
-- <table class="admin-table">
-- <thead>
-- <tr><th>User</th><th>Reason</th><th>By</th><th>Expires</th><th>Actions</th></tr>
-- </thead>
-- <tbody>
-- ${bans.map((ban) => `
-- <tr>
-- <td>${esc(ban.display_name || ban.username || ban.banned_username || ban.user_id)}</td>
-- <td>${esc(ban.reason || '-')}</td>
-- <td>${esc(ban.banned_by_username || '-')}</td>
-- <td>${ban.expires_at ? formatStaffDate(ban.expires_at) : 'Permanent'}</td>
-- <td>
-- <button class="btn btn-small" onclick="staffUnbanUser(${ban.user_id})"><i class="fa-solid fa-user-check"></i> Unban</button>
-- </td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>
-- ` : '<p class="muted">No active bans.</p>'}
-- </div>
-- </div>
-- `;
-- } catch (err) {
-- content.innerHTML = `<p class="muted">Failed to load bans: ${esc(err.message || 'Unknown error')}</p>`;
-- }
--}
--
--async function loadStaffActions() {
-- const content = document.getElementById('admin-content');
-- content.innerHTML = '<p class="muted">Loading moderation actions...</p>';
--
-- try {
-- const data = await api('/mod/actions?limit=100');
-- const actions = data.actions || [];
-- content.innerHTML = actions.length ? `
-- <table class="admin-table">
-- <thead>
-- <tr><th>Time</th><th>Scope</th><th>Action</th><th>Actor</th><th>Target</th><th>Details</th></tr>
-- </thead>
-- <tbody>
-- ${actions.map((action) => `
-- <tr>
-- <td>${formatStaffDate(action.created_at)}</td>
-- <td>${esc(action.scope_type || 'site')}${action.scope_id ? ` #${action.scope_id}` : ''}</td>
-- <td>${esc(action.action_type || '-')}</td>
-- <td>${esc(action.actor_username || '-')}</td>
-- <td>${esc(action.target_username || '-')}</td>
-- <td>${summarizeModAction(action)}</td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>
-- ` : '<p class="muted">No moderation actions logged yet.</p>';
-- } catch (err) {
-- content.innerHTML = `<p class="muted">Failed to load moderation actions: ${esc(err.message || 'Unknown error')}</p>`;
-- }
--}
--
--function renderCanvasHeatmap(heatmap) {
-- const buckets = heatmap?.buckets || [];
-- const users = heatmap?.users || [];
-- const ips = heatmap?.ips || [];
-- return `
-- <div class="staff-mini-grid">
-- <div class="staff-mini-card">
-- <h4>Hot Regions</h4>
-- ${buckets.length ? buckets.map((bucket) => `<div class="staff-mini-row">(${bucket.bucket_x}, ${bucket.bucket_y}) <strong>${bucket.placements}</strong></div>`).join('') : '<p class="muted">No heatmap data.</p>'}
-- </div>
-- <div class="staff-mini-card">
-- <h4>Top Users</h4>
-- ${users.length ? users.map((user) => `<div class="staff-mini-row">${esc(user.username || `User ${user.user_id || '?'}`)} <strong>${user.placements}</strong></div>`).join('') : '<p class="muted">No user data.</p>'}
-- </div>
-- <div class="staff-mini-card">
-- <h4>Top IPs</h4>
-- ${ips.length ? ips.map((ip) => `<div class="staff-mini-row">${esc(ip.ip_address || 'Unknown')} <strong>${ip.placements}</strong></div>`).join('') : '<p class="muted">No IP data.</p>'}
-- </div>
-- </div>
-- `;
--}
--
--async function loadStaffCanvas() {
-- const content = document.getElementById('admin-content');
-- content.innerHTML = '<p class="muted">Loading canvas tools...</p>';
--
-- try {
-- const requests = [
-- api('/game/canvas/state'),
-- api('/game/canvas/staff/actions?limit=80'),
-- api('/game/canvas/staff/bans'),
-- api('/game/canvas/staff/regions'),
-- api('/game/canvas/staff/snapshots'),
-- api('/game/canvas/staff/heatmap?hours=12'),
-- ];
-- if (hasCapability('can_manage_canvas_settings')) {
-- requests.push(api('/game/canvas/admin/overrides'));
-- requests.push(api('/game/canvas/admin/settings'));
-- }
--
-- const response = await Promise.all(requests);
-- const state = response[0];
-- const actions = response[1];
-- const bans = response[2];
-- const regions = response[3];
-- const snapshots = response[4];
-- const heatmap = response[5];
-- const overrides = hasCapability('can_manage_canvas_settings') ? response[6] : { overrides: [] };
-- const settings = hasCapability('can_manage_canvas_settings') ? response[7] : { settings: state.board };
--
-- staffCanvasCache = {
-- state,
-- actions: actions.actions || [],
-- bans: bans.bans || [],
-- regions: regions.regions || [],
-- snapshots: snapshots.snapshots || [],
-- heatmap,
-- overrides: overrides.overrides || [],
-- settings: settings.settings || {},
-- };
--
-- const boardState = state.settings || {};
-- const board = state.board || {};
-- const currentSettings = staffCanvasCache.settings;
--
-- content.innerHTML = `
-- <div class="staff-panel-grid">
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-chess-board"></i> Board Status</h3>
-- <div class="staff-note-list">
-- <div class="staff-note"><i class="fa-solid fa-expand"></i> ${board.width || 0} x ${board.height || 0}</div>
-- <div class="staff-note"><i class="fa-solid fa-fill-drip"></i> ${state.tiles?.length || 0} painted tiles</div>
-- <div class="staff-note"><i class="fa-solid fa-lock"></i> Read only: <strong>${boardState.read_only ? 'Yes' : 'No'}</strong></div>
-- <div class="staff-note"><i class="fa-solid fa-snowflake"></i> Frozen: <strong>${boardState.frozen ? 'Yes' : 'No'}</strong></div>
-- <div class="staff-note"><i class="fa-solid fa-stopwatch"></i> Tile overwrite lock: <strong>${boardState.tile_cooldown_seconds || 20}s</strong></div>
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-rotate-left"></i> Rollback</h3>
-- <div class="staff-form-grid">
-- <select id="canvas-rollback-mode" class="form-input">
-- <option value="tile">Single tile</option>
-- <option value="user">By user</option>
-- <option value="region">Rectangle</option>
-- <option value="time_range">Time range</option>
-- </select>
-- <input type="number" id="canvas-rollback-x" class="form-input" placeholder="X">
-- <input type="number" id="canvas-rollback-y" class="form-input" placeholder="Y">
-- <input type="number" id="canvas-rollback-user" class="form-input" placeholder="User ID">
-- <input type="number" id="canvas-rollback-x1" class="form-input" placeholder="X1">
-- <input type="number" id="canvas-rollback-y1" class="form-input" placeholder="Y1">
-- <input type="number" id="canvas-rollback-x2" class="form-input" placeholder="X2">
-- <input type="number" id="canvas-rollback-y2" class="form-input" placeholder="Y2">
-- <input type="datetime-local" id="canvas-rollback-start" class="form-input">
-- <input type="datetime-local" id="canvas-rollback-end" class="form-input">
-- </div>
-- <button class="btn btn-primary" onclick="staffRollbackCanvas()"><i class="fa-solid fa-rotate-left"></i> Run Rollback</button>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-user-slash"></i> Canvas Ban</h3>
-- <div class="staff-form-grid">
-- <input type="text" id="canvas-ban-username" class="form-input" placeholder="Username">
-- <input type="text" id="canvas-ban-ip" class="form-input" placeholder="IP address (optional)">
-- <select id="canvas-ban-action" class="form-input">
-- <option value="ban">Ban</option>
-- <option value="mute">Mute</option>
-- </select>
-- <input type="datetime-local" id="canvas-ban-expires" class="form-input">
-- <textarea id="canvas-ban-reason" class="form-input staff-form-wide" rows="2" placeholder="Reason"></textarea>
-- </div>
-- <button class="btn btn-primary" onclick="staffCreateCanvasBan()"><i class="fa-solid fa-plus"></i> Add Canvas Ban</button>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-draw-polygon"></i> Region Lock</h3>
-- <div class="staff-form-grid">
-- <input type="text" id="canvas-region-label" class="form-input" placeholder="Label">
-- <select id="canvas-region-mode" class="form-input">
-- <option value="locked">Locked</option>
-- <option value="protected">Protected</option>
-- </select>
-- <input type="number" id="canvas-region-x1" class="form-input" placeholder="X1">
-- <input type="number" id="canvas-region-y1" class="form-input" placeholder="Y1">
-- <input type="number" id="canvas-region-x2" class="form-input" placeholder="X2">
-- <input type="number" id="canvas-region-y2" class="form-input" placeholder="Y2">
-- <input type="datetime-local" id="canvas-region-expires" class="form-input">
-- <textarea id="canvas-region-reason" class="form-input staff-form-wide" rows="2" placeholder="Reason"></textarea>
-- </div>
-- <button class="btn btn-primary" onclick="staffCreateCanvasRegion()"><i class="fa-solid fa-lock"></i> Add Region</button>
-- </div>
-- </div>
--
-- <div class="staff-canvas-grid">
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-clock-rotate-left"></i> Recent Canvas Actions</h3>
-- <div class="staff-scroll-card">
-- ${staffCanvasCache.actions.length ? staffCanvasCache.actions.map((action) => `
-- <div class="staff-log-entry">
-- <strong>${esc(action.action_type || 'action')}</strong>
-- <span class="muted">${formatStaffDate(action.created_at)}</span>
-- <div class="muted">${esc(action.username || 'System')} at ${action.x ?? '-'}, ${action.y ?? '-'}</div>
-- </div>
-- `).join('') : '<p class="muted">No canvas actions yet.</p>'}
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-user-lock"></i> Active Canvas Bans</h3>
-- <div class="staff-scroll-card">
-- ${staffCanvasCache.bans.length ? staffCanvasCache.bans.map((ban) => `
-- <div class="staff-log-entry">
-- <strong>${esc(ban.username || ban.ip_address || `Ban #${ban.id}`)}</strong>
-- <span class="muted">${esc(ban.action_type || 'ban')} - ${ban.expires_at ? formatStaffDate(ban.expires_at) : 'Permanent'}</span>
-- <div class="muted">${esc(ban.reason || 'No reason')}</div>
-- <button class="btn btn-small" onclick="staffRemoveCanvasBan(${ban.id})"><i class="fa-solid fa-trash"></i> Remove</button>
-- </div>
-- `).join('') : '<p class="muted">No active canvas bans.</p>'}
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-border-all"></i> Region Locks</h3>
-- <div class="staff-scroll-card">
-- ${staffCanvasCache.regions.length ? staffCanvasCache.regions.map((region) => `
-- <div class="staff-log-entry">
-- <strong>${esc(region.label || `Region #${region.id}`)}</strong>
-- <span class="muted">${region.x1},${region.y1} to ${region.x2},${region.y2}</span>
-- <div class="muted">${esc(region.reason || region.mode || 'Locked')}</div>
-- <button class="btn btn-small" onclick="staffRemoveCanvasRegion(${region.id})"><i class="fa-solid fa-unlock"></i> Remove</button>
-- </div>
-- `).join('') : '<p class="muted">No active region locks.</p>'}
-- </div>
-- </div>
-- </div>
--
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-fire"></i> Canvas Heatmap</h3>
-- ${renderCanvasHeatmap(staffCanvasCache.heatmap)}
-- </div>
--
-- ${hasCapability('can_manage_canvas_settings') ? `
-- <div class="staff-panel-grid">
-- <div class="staff-panel-card staff-panel-card-wide">
-- <h3><i class="fa-solid fa-sliders"></i> Admin Canvas Settings</h3>
-- <div class="staff-settings-form">
-- <label><span>Frozen</span><input type="checkbox" id="canvas-setting-frozen" ${currentSettings.frozen ? 'checked' : ''}></label>
-- <label><span>Read Only</span><input type="checkbox" id="canvas-setting-readonly" ${currentSettings.read_only ? 'checked' : ''}></label>
-- <label><span>Tile Cooldown (s)</span><input type="number" id="canvas-setting-tile-cooldown" class="form-input" value="${Number(currentSettings.tile_cooldown_seconds || 20)}"></label>
-- <label><span>New Account Cooldown (s)</span><input type="number" id="canvas-setting-new-account-cooldown" class="form-input" value="${Number(currentSettings.new_account_cooldown_seconds || 12)}"></label>
-- <label class="staff-form-full"><span>Palette JSON</span><textarea id="canvas-setting-palette" class="form-input" rows="4">${esc(JSON.stringify(currentSettings.palette || [], null, 2))}</textarea></label>
-- <label class="staff-form-full"><span>Level Cooldowns JSON</span><textarea id="canvas-setting-levels" class="form-input" rows="6">${esc(JSON.stringify(currentSettings.level_cooldowns || [], null, 2))}</textarea></label>
-- </div>
-- <div class="staff-button-row">
-- <button class="btn btn-primary" onclick="staffSaveCanvasSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button>
-- <button class="btn btn-outline" onclick="staffCreateCanvasSnapshot()"><i class="fa-solid fa-camera"></i> Snapshot</button>
-- <button class="btn btn-danger" onclick="staffWipeCanvas()"><i class="fa-solid fa-trash"></i> Wipe Board</button>
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-camera-retro"></i> Snapshots</h3>
-- <div class="staff-scroll-card">
-- ${staffCanvasCache.snapshots.length ? staffCanvasCache.snapshots.map((snapshot) => `
-- <div class="staff-log-entry">
-- <strong>${esc(snapshot.name || `Snapshot ${snapshot.id}`)}</strong>
-- <span class="muted">${formatStaffDate(snapshot.created_at)}</span>
-- <div class="muted">${snapshot.metadata?.tile_count || 0} tiles</div>
-- <button class="btn btn-small" onclick="staffRestoreCanvasSnapshot(${snapshot.id})"><i class="fa-solid fa-rotate-left"></i> Restore</button>
-- </div>
-- `).join('') : '<p class="muted">No snapshots yet.</p>'}
-- </div>
-- </div>
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-user-gear"></i> User Override</h3>
-- <div class="staff-form-grid">
-- <input type="text" id="canvas-override-username" class="form-input" placeholder="Username">
-- <input type="number" id="canvas-override-cooldown" class="form-input" placeholder="Cooldown seconds">
-- <input type="number" id="canvas-override-rate" class="form-input" placeholder="Placements per minute">
-- <label class="staff-inline-toggle"><input type="checkbox" id="canvas-override-bypass"> Bypass read only</label>
-- <textarea id="canvas-override-note" class="form-input staff-form-wide" rows="2" placeholder="Note"></textarea>
-- </div>
-- <button class="btn btn-primary" onclick="staffSaveCanvasOverride()"><i class="fa-solid fa-plus"></i> Save Override</button>
-- <div class="staff-scroll-card" style="margin-top:12px">
-- ${staffCanvasCache.overrides.length ? staffCanvasCache.overrides.map((override) => `
-- <div class="staff-log-entry">
-- <strong>${esc(override.username || `User ${override.user_id}`)}</strong>
-- <span class="muted">Cooldown ${override.cooldown_seconds ?? 'default'}s - Rate ${override.placements_per_minute ?? 'default'}/min</span>
-- <div class="muted">${esc(override.note || '')}</div>
-- <button class="btn btn-small" onclick="staffRemoveCanvasOverride(${override.user_id})"><i class="fa-solid fa-trash"></i> Remove</button>
-- </div>
-- `).join('') : '<p class="muted">No canvas user overrides.</p>'}
-- </div>
-- </div>
-- </div>
-- ` : `
-- <div class="staff-panel-card">
-- <h3><i class="fa-solid fa-camera-retro"></i> Snapshots</h3>
-- <div class="staff-scroll-card">
-- ${staffCanvasCache.snapshots.length ? staffCanvasCache.snapshots.map((snapshot) => `
-- <div class="staff-log-entry">
-- <strong>${esc(snapshot.name || `Snapshot ${snapshot.id}`)}</strong>
-- <span class="muted">${formatStaffDate(snapshot.created_at)}</span>
-- </div>
-- `).join('') : '<p class="muted">No snapshots yet.</p>'}
-- </div>
-- </div>
-- `}
-- `;
-- } catch (err) {
-- content.innerHTML = `<p class="muted">Failed to load canvas tools: ${esc(err.message || 'Unknown error')}</p>`;
-- }
--}
--
--window.staffSearchLogs = async function staffSearchLogs() {
-- staffLogsQuery = document.getElementById('staff-log-search')?.value?.trim() || '';
-- staffLogsUserId = document.getElementById('staff-log-userid')?.value?.trim() || '';
-- staffLogsOffset = 0;
-- await fetchStaffLogs();
--};
--
--window.staffLogsPrev = async function staffLogsPrev() {
-- staffLogsOffset = Math.max(0, staffLogsOffset - 50);
-- await fetchStaffLogs();
--};
--
--window.staffLogsNext = async function staffLogsNext() {
-- staffLogsOffset += 50;
-- await fetchStaffLogs();
--};
--
--window.staffOpenLogsForUser = async function staffOpenLogsForUser(userId, username) {
-- staffLogsUserId = String(userId || '');
-- staffLogsQuery = '';
-- currentAdminTab = 'chat';
-- renderStaffTabs();
-- await loadStaffChatLogs();
-- const searchField = document.getElementById('staff-log-search');
-- const userField = document.getElementById('staff-log-userid');
-- if (searchField) searchField.value = '';
-- if (userField) userField.value = staffLogsUserId;
-- toast(`Showing chat logs for ${username}`, 'info');
-- await fetchStaffLogs();
--};
--
--window.staffForceEndStream = async function staffForceEndStream(streamId) {
-- if (!confirm('Force end this stream?')) return;
-- try {
-- await api(`/mod/streams/${streamId}`, { method: 'DELETE' });
-- toast('Stream ended', 'success');
-- await loadStaffStats();
-- await loadStaffStreams();
-- } catch (err) {
-- toast(err.message || 'Failed to end stream', 'error');
-- }
--};
--
--window.staffBanUser = async function staffBanUser(userId, username, reason = 'Banned via staff tools', durationHours = 0) {
-- try {
-- await api(`/mod/users/${userId}/ban`, {
-- method: 'POST',
-- body: {
-- reason,
-- duration_hours: durationHours,
-- },
-- });
-- toast(`${username || 'User'} banned`, 'success');
-- if (currentAdminTab === 'bans') await loadStaffBans();
-- await loadStaffStats();
-- } catch (err) {
-- toast(err.message || 'Failed to ban user', 'error');
-- }
--};
--
--window.staffCreateSiteBan = async function staffCreateSiteBan() {
-- const username = document.getElementById('staff-ban-username')?.value?.trim();
-- const reason = document.getElementById('staff-ban-reason')?.value?.trim() || 'Banned by staff';
-- const durationHours = Number(document.getElementById('staff-ban-duration')?.value || 0);
-- if (!username) return toast('Enter a username to ban.', 'error');
--
-- try {
-- const userId = await resolveUsernameToUserId(username);
-- if (!userId) throw new Error('User not found');
-- await window.staffBanUser(userId, username, reason, durationHours);
-- } catch (err) {
-- toast(err.message || 'Failed to resolve user', 'error');
-- }
--};
--
--window.staffUnbanUser = async function staffUnbanUser(userId) {
-- try {
-- await api(`/mod/users/${userId}/ban`, { method: 'DELETE' });
-- toast('User unbanned', 'success');
-- await loadStaffBans();
-- await loadStaffStats();
-- } catch (err) {
-- toast(err.message || 'Failed to unban user', 'error');
-- }
--};
--
--window.staffCreateCanvasBan = async function staffCreateCanvasBan() {
-- const username = document.getElementById('canvas-ban-username')?.value?.trim();
-- const ipAddress = document.getElementById('canvas-ban-ip')?.value?.trim();
-- const actionType = document.getElementById('canvas-ban-action')?.value || 'ban';
-- const expiresAt = toIsoFromInput(document.getElementById('canvas-ban-expires')?.value);
-- const reason = document.getElementById('canvas-ban-reason')?.value?.trim() || 'Canvas moderation action';
--
-- if (!username && !ipAddress) return toast('Provide a username or IP address.', 'error');
--
-- try {
-- const payload = {
-- ip_address: ipAddress || null,
-- action_type: actionType,
-- reason,
-- expires_at: expiresAt,
-- };
-- if (username) payload.user_id = await resolveUsernameToUserId(username);
-- await api('/game/canvas/staff/bans', { method: 'POST', body: payload });
-- toast('Canvas ban saved', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to create canvas ban', 'error');
-- }
--};
--
--window.staffRemoveCanvasBan = async function staffRemoveCanvasBan(id) {
-- try {
-- await api(`/game/canvas/staff/bans/${id}`, { method: 'DELETE' });
-- toast('Canvas ban removed', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to remove canvas ban', 'error');
-- }
--};
--
--window.staffCreateCanvasRegion = async function staffCreateCanvasRegion() {
-- try {
-- await api('/game/canvas/staff/regions', {
-- method: 'POST',
-- body: {
-- label: document.getElementById('canvas-region-label')?.value?.trim() || '',
-- mode: document.getElementById('canvas-region-mode')?.value || 'locked',
-- x1: Number(document.getElementById('canvas-region-x1')?.value || 0),
-- y1: Number(document.getElementById('canvas-region-y1')?.value || 0),
-- x2: Number(document.getElementById('canvas-region-x2')?.value || 0),
-- y2: Number(document.getElementById('canvas-region-y2')?.value || 0),
-- expires_at: toIsoFromInput(document.getElementById('canvas-region-expires')?.value),
-- reason: document.getElementById('canvas-region-reason')?.value?.trim() || '',
-- },
-- });
-- toast('Canvas region saved', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to save canvas region', 'error');
-- }
--};
--
--window.staffRemoveCanvasRegion = async function staffRemoveCanvasRegion(id) {
-- try {
-- await api(`/game/canvas/staff/regions/${id}`, { method: 'DELETE' });
-- toast('Canvas region removed', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to remove canvas region', 'error');
-- }
--};
--
--window.staffRollbackCanvas = async function staffRollbackCanvas() {
-- const payload = {
-- mode: document.getElementById('canvas-rollback-mode')?.value || 'tile',
-- x: Number(document.getElementById('canvas-rollback-x')?.value || 0),
-- y: Number(document.getElementById('canvas-rollback-y')?.value || 0),
-- user_id: Number(document.getElementById('canvas-rollback-user')?.value || 0),
-- x1: Number(document.getElementById('canvas-rollback-x1')?.value || 0),
-- y1: Number(document.getElementById('canvas-rollback-y1')?.value || 0),
-- x2: Number(document.getElementById('canvas-rollback-x2')?.value || 0),
-- y2: Number(document.getElementById('canvas-rollback-y2')?.value || 0),
-- start_at: toIsoFromInput(document.getElementById('canvas-rollback-start')?.value),
-- end_at: toIsoFromInput(document.getElementById('canvas-rollback-end')?.value),
-- };
--
-- try {
-- const data = await api('/game/canvas/staff/rollback', { method: 'POST', body: payload });
-- toast(`Rolled back ${data.count || 0} tiles`, 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Canvas rollback failed', 'error');
-- }
--};
--
--window.staffSaveCanvasSettings = async function staffSaveCanvasSettings() {
-- try {
-- await api('/game/canvas/admin/settings', {
-- method: 'POST',
-- body: {
-- frozen: !!document.getElementById('canvas-setting-frozen')?.checked,
-- read_only: !!document.getElementById('canvas-setting-readonly')?.checked,
-- tile_cooldown_seconds: Number(document.getElementById('canvas-setting-tile-cooldown')?.value || 20),
-- new_account_cooldown_seconds: Number(document.getElementById('canvas-setting-new-account-cooldown')?.value || 12),
-- palette: JSON.parse(document.getElementById('canvas-setting-palette')?.value || '[]'),
-- level_cooldowns: JSON.parse(document.getElementById('canvas-setting-levels')?.value || '[]'),
-- },
-- });
-- toast('Canvas settings saved', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to save canvas settings', 'error');
-- }
--};
--
--window.staffCreateCanvasSnapshot = async function staffCreateCanvasSnapshot() {
-- const name = prompt('Snapshot name:', `Canvas snapshot ${new Date().toLocaleString()}`);
-- if (name === null) return;
-- try {
-- await api('/game/canvas/admin/snapshots', { method: 'POST', body: { name } });
-- toast('Canvas snapshot created', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to create snapshot', 'error');
-- }
--};
--
--window.staffRestoreCanvasSnapshot = async function staffRestoreCanvasSnapshot(id) {
-- if (!confirm('Restore this snapshot? This rewrites the board state.')) return;
-- try {
-- await api(`/game/canvas/admin/snapshots/${id}/restore`, { method: 'POST' });
-- toast('Canvas snapshot restored', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to restore snapshot', 'error');
-- }
--};
--
--window.staffWipeCanvas = async function staffWipeCanvas() {
-- if (!confirm('Wipe the full canvas board? This cannot be undone without a snapshot restore.')) return;
-- try {
-- await api('/game/canvas/admin/wipe', { method: 'POST' });
-- toast('Canvas wiped', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to wipe canvas', 'error');
-- }
--};
--
--window.staffSaveCanvasOverride = async function staffSaveCanvasOverride() {
-- const username = document.getElementById('canvas-override-username')?.value?.trim();
-- if (!username) return toast('Enter a username for the override.', 'error');
--
-- try {
-- const userId = await resolveUsernameToUserId(username);
-- await api('/game/canvas/admin/overrides', {
-- method: 'POST',
-- body: {
-- user_id: userId,
-- cooldown_seconds: document.getElementById('canvas-override-cooldown')?.value ? Number(document.getElementById('canvas-override-cooldown').value) : null,
-- placements_per_minute: document.getElementById('canvas-override-rate')?.value ? Number(document.getElementById('canvas-override-rate').value) : null,
-- bypass_read_only: !!document.getElementById('canvas-override-bypass')?.checked,
-- note: document.getElementById('canvas-override-note')?.value?.trim() || '',
-- },
-- });
-- toast('Canvas override saved', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to save override', 'error');
-- }
--};
--
--window.staffRemoveCanvasOverride = async function staffRemoveCanvasOverride(userId) {
-- try {
-- await api(`/game/canvas/admin/overrides/${userId}`, { method: 'DELETE' });
-- toast('Canvas override removed', 'success');
-- await loadStaffCanvas();
-- } catch (err) {
-- toast(err.message || 'Failed to remove override', 'error');
-- }
--};
--
--window.loadAdmin = async function loadAdminOverride() {
-- if (!isStaffUser()) {
-- toast('Staff access required', 'error');
-- return navigate('/');
-- }
--
-- currentAdminTab = ensureAllowedStaffTab(currentAdminTab || 'overview');
-- renderStaffTabs();
-- await loadStaffStats();
-- await window.switchAdminTab(currentAdminTab);
--};
--
--window.switchAdminTab = async function switchAdminTabOverride(tab) {
-- currentAdminTab = ensureAllowedStaffTab(tab);
-- renderStaffTabs();
--
-- if (LEGACY_ADMIN_TAB_LOADERS[currentAdminTab]) {
-- LEGACY_ADMIN_TAB_LOADERS[currentAdminTab]();
-- return;
-- }
--
-- switch (currentAdminTab) {
-- case 'overview':
-- await loadStaffOverview();
-- break;
-- case 'chat':
-- await loadStaffChatLogs();
-- break;
-- case 'streams':
-- await loadStaffStreams();
-- break;
-- case 'bans':
-- await loadStaffBans();
-- break;
-- case 'actions':
-- await loadStaffActions();
-- break;
-- case 'canvas':
-- await loadStaffCanvas();
-- break;
-- default:
-- document.getElementById('admin-content').innerHTML = '<p class="muted">Staff panel is loading...</p>';
-- break;
-- }
--};
--
--/* These three lines reassigned admin.js's globals to this module's versions. admin.js is gone
-- (the /admin route redirects off-site, so none of it was reachable), and with it the dead
-- console UI these names drove. staffBanUser below is the only export anything still calls — from
-- chat.js and app.js's channel moderation — and it is unaffected. */
--window.loadAdminStats = loadStaffStats;
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex bc65bcc9..1798dc04 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -64,73 +64,90 @@ router.get('/bans', permissions.requireGlobalMod, (req, res) => {
// ── Global Ban (admin + global_mod) ──────────────────────────
// Site-wide ban: sets is_banned flag, creates bans entry with no stream_id, adds IP ban.
// Also cascades: bans all other accounts that share this user's IP.
--router.post('/global-ban', permissions.requireGlobalMod, (req, res) => {
-- try {
-- const { user_id, reason, duration_hours, ip_address } = req.body;
-- if (!user_id) return res.status(400).json({ error: 'user_id required' });
--
-- const targetUser = db.getUserById(parseInt(user_id));
-- if (!targetUser) return res.status(404).json({ error: 'User not found' });
++/**
++ * Site-wide ban: the account, the IP it was last seen on, and every other account that has used
++ * that IP.
++ *
++ * /global-ban and /users/:id/ban used to be two copies of this code. Neither checked who the
++ * cascade was about to hit. getLinkedAccounts() returns every account that ever shared an IP with
++ * the target — and on production all three admin accounts share an IP with some other account,
++ * because staff browse from the same home and phone networks as people who have been banned. The
++ * IP-ban exemption for admins only applies to sessions that are not themselves banned, so a
++ * cascade that set is_banned on an admin would have locked them out of their own site.
++ *
++ * Staff are now never swept up by the cascade, nobody can ban themselves by association, and a
++ * moderator cannot ban someone of equal or higher rank directly either.
++ */
++function performGlobalBan(req, res, { userId, reason, durationHours, ipAddress }) {
++ const targetUser = db.getUserById(parseInt(userId));
++ if (!targetUser) return res.status(404).json({ error: 'User not found' });
++ if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot ban yourself' });
++ if (permissions.isStaff(targetUser) && permissions.roleRank(targetUser.role) >= permissions.roleRank(req.user.role)) {
++ return res.status(403).json({ error: 'You cannot ban staff of equal or higher rank' });
++ }
-- const banReason = reason || 'Banned by moderator';
-- const expires = duration_hours
-- ? new Date(Date.now() + parseInt(duration_hours) * 3600000).toISOString()
-- : null;
++ const banReason = reason || 'Banned by moderator';
++ const expires = durationHours
++ ? new Date(Date.now() + parseInt(durationHours) * 3600000).toISOString()
++ : null;
-- // Auto-detect IP from connected WebSocket clients, then fall back to IP log
-- let resolvedIp = ip_address || chatServer.getConnectedUserIp(targetUser.id);
-- if (!resolvedIp) {
-- const latest = db.getLatestIpForUser(targetUser.id);
-- if (latest) resolvedIp = latest.ip_address;
-- }
++ // Auto-detect IP from connected WebSocket clients, then fall back to IP log
++ let resolvedIp = ipAddress || chatServer.getConnectedUserIp(targetUser.id);
++ if (!resolvedIp) {
++ const latest = db.getLatestIpForUser(targetUser.id);
++ if (latest) resolvedIp = latest.ip_address;
++ }
-- // Set the site-wide is_banned flag
-- db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?',
-- [banReason, targetUser.id]);
++ // Set the site-wide is_banned flag
++ db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?', [banReason, targetUser.id]);
++ db.run(
++ `INSERT INTO bans (user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
++ [targetUser.id, resolvedIp, banReason, req.user.id, expires]
++ );
-- // Create user-based global ban
++ let cascadeBanned = 0, cascadeSkippedStaff = 0;
++ if (resolvedIp) {
++ // Standalone IP ban (catches future visits and new alts)
db.run(
-- `INSERT INTO bans (user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
-- [targetUser.id, resolvedIp, banReason, req.user.id, expires]
++ `INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
++ [resolvedIp, banReason + ` (IP of ${targetUser.username})`, req.user.id, expires]
);
--
-- // Also create standalone IP ban for the IP (catches alt accounts + future visits)
-- if (resolvedIp) {
-- db.run(
-- `INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
-- [resolvedIp, banReason + ` (IP of ${targetUser.username})`, req.user.id, expires]
-- );
--
-- // Cascade: ban all other accounts that have ever used this IP
-- const linked = db.getLinkedAccounts(targetUser.id);
-- let cascadeBanned = 0;
-- for (const alt of linked) {
-- if (alt.is_banned) continue; // already banned
-- db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?',
-- [banReason + ` (alt of ${targetUser.username})`, alt.id]);
-- db.run(`INSERT INTO bans (user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
-- [alt.id, banReason + ` (alt of ${targetUser.username})`, req.user.id, expires]);
-- chatServer.disconnectUser({ userId: alt.id });
-- cascadeBanned++;
-- }
--
-- if (cascadeBanned > 0) {
-- console.log(`[Mod] Global ban cascade: ${cascadeBanned} alt accounts of ${targetUser.username} also banned`);
-- }
++ // Cascade: ban the other accounts that have used this IP — except staff and the moderator
++ // doing the banning.
++ for (const alt of db.getLinkedAccounts(targetUser.id)) {
++ if (alt.is_banned) continue;
++ if (alt.id === req.user.id || permissions.isStaff(alt)) { cascadeSkippedStaff++; continue; }
++ db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?',
++ [banReason + ` (alt of ${targetUser.username})`, alt.id]);
++ db.run(`INSERT INTO bans (user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
++ [alt.id, banReason + ` (alt of ${targetUser.username})`, req.user.id, expires]);
++ chatServer.disconnectUser({ userId: alt.id });
++ cascadeBanned++;
}
++ if (cascadeBanned || cascadeSkippedStaff) {
++ console.log(`[Mod] Global ban cascade for ${targetUser.username}: ${cascadeBanned} alt account(s) banned, ${cascadeSkippedStaff} staff account(s) on a shared IP left alone`);
++ }
++ }
-- // Immediately disconnect the user from chat
-- chatServer.disconnectUser({ userId: targetUser.id, ip: resolvedIp });
++ // Immediately disconnect the user from chat
++ chatServer.disconnectUser({ userId: targetUser.id, ip: resolvedIp });
-- db.logModerationAction({
-- scope_type: 'site',
-- actor_user_id: req.user.id,
-- target_user_id: targetUser.id,
-- action_type: 'global_ban',
-- details: { reason: banReason, duration_hours: duration_hours || null, ip: resolvedIp },
-- });
++ db.logModerationAction({
++ scope_type: 'site',
++ actor_user_id: req.user.id,
++ target_user_id: targetUser.id,
++ action_type: 'global_ban',
++ details: { reason: banReason, duration_hours: durationHours || null, ip: resolvedIp, cascade_banned: cascadeBanned, cascade_skipped_staff: cascadeSkippedStaff },
++ });
++
++ return res.json({ message: `${targetUser.username} globally banned` });
++}
-- res.json({ message: `${targetUser.username} globally banned` });
++router.post('/global-ban', permissions.requireGlobalMod, (req, res) => {
++ try {
++ const { user_id, reason, duration_hours, ip_address } = req.body;
++ if (!user_id) return res.status(400).json({ error: 'user_id required' });
++ return performGlobalBan(req, res, { userId: user_id, reason, durationHours: duration_hours, ipAddress: ip_address });
} catch (err) {
console.error('[Mod] Global ban error:', err.message);
res.status(500).json({ error: 'Failed to ban user' });
@@@ -138,60 +155,11 @@ router.post('/global-ban', permissions.requireGlobalMod, (req, res) => {
});
// ── Per-User Ban/Unban (staff console) ───────────────────────
--// Aliases for /global-ban and unban — the staff console calls /mod/users/:id/ban
++// Same ban, addressed by URL; both routes share performGlobalBan so they cannot drift apart again.
router.post('/users/:id/ban', permissions.requireGlobalMod, (req, res) => {
try {
-- const userId = parseInt(req.params.id);
const { reason, duration_hours } = req.body;
--
-- const targetUser = db.getUserById(userId);
-- if (!targetUser) return res.status(404).json({ error: 'User not found' });
--
-- const banReason = reason || 'Banned by moderator';
-- const expires = duration_hours
-- ? new Date(Date.now() + parseInt(duration_hours) * 3600000).toISOString()
-- : null;
--
-- let resolvedIp = chatServer.getConnectedUserIp(targetUser.id);
-- if (!resolvedIp) {
-- const latest = db.getLatestIpForUser(targetUser.id);
-- if (latest) resolvedIp = latest.ip_address;
-- }
--
-- db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?',
-- [banReason, targetUser.id]);
-- db.run(
-- `INSERT INTO bans (user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
-- [targetUser.id, resolvedIp, banReason, req.user.id, expires]
-- );
--
-- if (resolvedIp) {
-- db.run(
-- `INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
-- [resolvedIp, banReason + ` (IP of ${targetUser.username})`, req.user.id, expires]
-- );
-- const linked = db.getLinkedAccounts(targetUser.id);
-- for (const alt of linked) {
-- if (alt.is_banned) continue;
-- db.run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ?',
-- [banReason + ` (alt of ${targetUser.username})`, alt.id]);
-- db.run(`INSERT INTO bans (user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
-- [alt.id, banReason + ` (alt of ${targetUser.username})`, req.user.id, expires]);
-- chatServer.disconnectUser({ userId: alt.id });
-- }
-- }
--
-- chatServer.disconnectUser({ userId: targetUser.id, ip: resolvedIp });
--
-- db.logModerationAction({
-- scope_type: 'site',
-- actor_user_id: req.user.id,
-- target_user_id: targetUser.id,
-- action_type: 'global_ban',
-- details: { reason: banReason, duration_hours: duration_hours || null, ip: resolvedIp },
-- });
--
-- res.json({ message: `${targetUser.username} globally banned` });
++ return performGlobalBan(req, res, { userId: req.params.id, reason, durationHours: duration_hours });
} catch (err) {
console.error('[Mod] User ban error:', err.message);
res.status(500).json({ error: 'Failed to ban user' });
@@@ -852,10 +820,15 @@ router.post('/ip/ban-all', permissions.requireGlobalMod, (req, res) => {
scope_type: 'site',
actor_user_id: req.user.id,
action_type: 'ip_ban_all',
-- details: { ip, reason: banReason, banned_user_ids: bannedIds, duration_hours: duration_hours || null },
++ details: { ip, reason: banReason, banned_user_ids: [...bannedIds], skipped_staff_ids: bannedIds.skippedStaff || [], duration_hours: duration_hours || null },
});
-- res.json({ message: `Banned IP ${ip} and ${bannedIds.length} associated account(s)`, banned_user_ids: bannedIds });
++ const skipped = (bannedIds.skippedStaff || []).length;
++ res.json({
++ message: `Banned IP ${ip} and ${bannedIds.length} associated account(s)` + (skipped ? ` — ${skipped} staff account(s) on this IP were not banned` : ''),
++ banned_user_ids: [...bannedIds],
++ skipped_staff_ids: bannedIds.skippedStaff || [],
++ });
} catch (err) {
console.error('[Mod] IP ban-all error:', err.message);
res.status(500).json({ error: 'Failed to ban IP' });
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 66734b12..0a6271c5 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -214,6 +214,8 @@ router.put('/users/:id', (req, res) => {
router.post('/users/:id/ban', (req, res) => {
try {
const { reason, duration_hours } = req.body;
++ // An admin banning their own account locks them out; there is no one above them to undo it.
++ if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'You cannot ban yourself' });
const expires = duration_hours
? new Date(Date.now() + duration_hours * 3600000).toISOString()
: null;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a0726d07..c39170ed 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -7906,15 +7906,23 @@ function getIpLog({ userId, anonId, ip, action, limit = 100, offset = 0 } = {})
* Ban all accounts sharing an IP. Returns the list of user IDs banned.
*/
function banAllAccountsOnIp(ip, { reason, bannedBy, expires }) {
++ // Staff and the moderator issuing the ban are never swept up. Staff browse from the same shared
++ // home and mobile networks as people who get banned, and the IP-ban exemption for admins only
++ // covers sessions that are not themselves banned — so banning an admin account by IP would
++ // lock them out of the site entirely. On production every admin shares some IP with another
++ // account. Returns the banned user ids; `skippedStaff` on the array lists who was left alone.
const users = all(`
-- SELECT DISTINCT il.user_id
++ SELECT DISTINCT il.user_id, u.role
FROM ip_log il
++ JOIN users u ON u.id = il.user_id
WHERE il.ip_address = ? AND il.user_id IS NOT NULL
`, [ip]);
const bannedIds = [];
++ const skippedStaff = [];
for (const row of users) {
if (!row.user_id) continue;
++ if (row.user_id === bannedBy || row.role === 'admin' || row.role === 'global_mod') { skippedStaff.push(row.user_id); continue; }
// Set is_banned flag
run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ? AND is_banned = 0', [reason, row.user_id]);
// Create global user ban
@@@ -7926,6 +7934,7 @@ function banAllAccountsOnIp(ip, { reason, bannedBy, expires }) {
run(`INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
[ip, reason, bannedBy, expires || null]);
++ bannedIds.skippedStaff = skippedStaff;
return bannedIds;
}
ddiff --git a/test/ban-cascade.test.js b/test/ban-cascade.test.js
nnew file mode 100644
iindex 00000000..b2aa97c2
---- /dev/null
++++ b/test/ban-cascade.test.js
@@@ -0,0 +1,111 @@
++/**
++ * Site bans must never sweep up staff.
++ *
++ * Staff browse from the same home and mobile networks as people who get banned (on production all
++ * three admins share an IP with some other account), and the admin exemption from IP bans only
++ * covers sessions that are not themselves banned. A cascade or IP-wide ban that set is_banned on an
++ * admin would lock them out of their own site. These run the real SQL against an in-memory DB.
++ *
++ * node test/ban-cascade.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++let Database;
++try { Database = require('better-sqlite3'); }
++catch { Database = require('/home/workstation/OpenVibers/OpenVibe.Live/node_modules/better-sqlite3'); }
++
++const ROOT = path.join(__dirname, '..');
++let pass = 0;
++const ok = (n) => { pass++; console.log(' ok -', n); };
++
++function freshDb() {
++ const d = new Database(':memory:');
++ d.exec(`
++ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, role TEXT DEFAULT 'user', is_banned INTEGER DEFAULT 0, ban_reason TEXT);
++ CREATE TABLE ip_log (id INTEGER PRIMARY KEY, user_id INTEGER, anon_id TEXT, ip_address TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP);
++ CREATE TABLE bans (id INTEGER PRIMARY KEY, user_id INTEGER, ip_address TEXT, reason TEXT, banned_by INTEGER, expires_at TEXT);
++ `);
++ const u = d.prepare('INSERT INTO users (id, username, role) VALUES (?, ?, ?)');
++ u.run(1, 'owner', 'admin'); u.run(2, 'mod', 'global_mod'); u.run(3, 'troll', 'user'); u.run(4, 'trollalt', 'user'); u.run(5, 'bystander', 'streamer');
++ const ip = d.prepare('INSERT INTO ip_log (user_id, ip_address) VALUES (?, ?)');
++ // One shared household IP: the admin, the troll, the troll's alt and a streamer all used it.
++ for (const id of [1, 3, 4, 5]) ip.run(id, '203.0.113.7');
++ ip.run(2, '198.51.100.2'); ip.run(3, '198.51.100.2'); // the mod shares a different IP with the troll
++ return d;
++}
++const wrap = (d) => ({
++ all: (sql, p = []) => d.prepare(sql).all(...p),
++ get: (sql, p = []) => d.prepare(sql).get(...p),
++ run: (sql, p = []) => d.prepare(sql).run(...p),
++});
++
++// ── banAllAccountsOnIp, the real function body ───────────────────────────────
++{
++ const src = fs.readFileSync(path.join(ROOT, 'server/db/database.js'), 'utf8');
++ const m = src.match(/function banAllAccountsOnIp\([^)]*\) \{[\s\S]*?\n\}/);
++ assert(m, 'banAllAccountsOnIp should exist');
++ const d = freshDb(); const h = wrap(d);
++ // eslint-disable-next-line no-new-func
++ const fn = new Function('all', 'run', `${m[0]}; return banAllAccountsOnIp;`)(h.all, h.run);
++ const banned = fn('203.0.113.7', { reason: 'test', bannedBy: 5, expires: null });
++ const flag = (id) => d.prepare('SELECT is_banned FROM users WHERE id = ?').get(id).is_banned;
++ assert.strictEqual(flag(1), 0, 'the admin on the shared IP must not be banned');
++ assert.strictEqual(flag(5), 0, 'the moderator issuing the ban must not ban themselves');
++ assert.strictEqual(flag(3), 1, 'the troll is banned');
++ assert.strictEqual(flag(4), 1, 'the alt is banned');
++ assert.deepStrictEqual([...banned].sort(), [3, 4]);
++ assert.deepStrictEqual([...banned.skippedStaff].sort(), [1, 5]);
++ ok('IP-wide ban skips staff and the acting moderator, bans everyone else');
++}
++
++// ── performGlobalBan: cascade and rank checks ────────────────────────────────
++{
++ const src = fs.readFileSync(path.join(ROOT, 'server/admin/mod-routes.js'), 'utf8');
++ const m = src.match(/function performGlobalBan\([^)]*\) \{[\s\S]*?\n\}/);
++ assert(m, 'performGlobalBan should exist');
++ const src2 = src.slice(src.indexOf("router.post('/global-ban'"), src.indexOf("router.delete('/users/:id/ban'"));
++ assert(/performGlobalBan\(req, res/.test(src2) && (src2.match(/performGlobalBan\(req, res/g) || []).length === 2,
++ 'both ban routes must go through performGlobalBan');
++
++ const RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
++ const permissions = { roleRank: (r) => RANK[r] ?? 0, isStaff: (u) => (RANK[u && u.role] ?? 0) >= 2 };
++ const run = (actorId, targetId) => {
++ const d = freshDb(); const h = wrap(d);
++ const db = {
++ ...h,
++ getUserById: (id) => d.prepare('SELECT * FROM users WHERE id = ?').get(id),
++ getLatestIpForUser: (id) => d.prepare('SELECT ip_address FROM ip_log WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(id),
++ getLinkedAccounts: (uid) => d.prepare(`SELECT u.id, u.username, u.role, u.is_banned FROM ip_log mine
++ JOIN ip_log shared ON mine.ip_address = shared.ip_address AND shared.user_id != ?
++ JOIN users u ON shared.user_id = u.id WHERE mine.user_id = ? GROUP BY shared.user_id`).all(uid, uid),
++ logModerationAction: () => {},
++ };
++ const chatServer = { getConnectedUserIp: () => '203.0.113.7', disconnectUser: () => {} };
++ // eslint-disable-next-line no-new-func
++ const fn = new Function('db', 'chatServer', 'permissions', 'console', `${m[0]}; return performGlobalBan;`)(db, chatServer, permissions, { log() {} });
++ let status = 200, body = null;
++ const res = { status(c) { status = c; return this; }, json(b) { body = b; return this; } };
++ const actor = db.getUserById(actorId);
++ fn({ user: actor }, res, { userId: targetId, reason: 'test' });
++ return { status, body, flag: (id) => d.prepare('SELECT is_banned FROM users WHERE id = ?').get(id).is_banned };
++ };
++
++ const r1 = run(2, 3); // the mod bans the troll, whose IP the admin also used
++ assert.strictEqual(r1.status, 200);
++ assert.strictEqual(r1.flag(3), 1, 'target banned');
++ assert.strictEqual(r1.flag(4), 1, 'alt on the shared IP banned');
++ assert.strictEqual(r1.flag(1), 0, 'the admin on the shared IP is not cascade-banned');
++ assert.strictEqual(r1.flag(2), 0, 'the moderator is not cascade-banned');
++ ok('global ban cascade bans alts but never staff on a shared IP');
++
++ const r2 = run(2, 1); // a global mod tries to ban an admin
++ assert.strictEqual(r2.status, 403, 'a moderator cannot ban higher-ranked staff');
++ assert.strictEqual(r2.flag(1), 0);
++ const r3 = run(2, 2);
++ assert.strictEqual(r3.status, 400, 'nobody can ban themselves');
++ ok('bans on equal/higher-ranked staff and self-bans are refused');
++}
++
++console.log(`\n${pass} checks passed`);