openvibers/openvibe.live Commits
Bans never sweep up staff; chat's one-click global ban gets its confirmation back
Changes
@@@ -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>ddeleted file mode 100644@@@ -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;@@@ -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' });@@@ -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;@@@ -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.roleFROM ip_log il++ JOIN users u ON u.id = il.user_idWHERE 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 flagrun('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;}nnew file mode 100644@@@ -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`);
@@ -5206,7 +5206,6 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" 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/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=3" defer></script>
<script src="/js/ai-viewers.js?v=3" defer></script>
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/recap.js?v=1" defer></script>
deleted file mode 100644
deleted file mode 100644
@@ -1,944 +0,0 @@
@@ -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;
@@ -64,73 +64,90 @@ router.get('/bans', permissions.requireGlobalMod, (req, res) => {
@@ -64,73 +64,90 @@ router.get('/bans', permissions.requireGlobalMod, (req, res) => {
// ── Global Ban (admin + global_mod) ──────────────────────────
// ── Global Ban (admin + global_mod) ──────────────────────────
// Site-wide ban: sets is_banned flag, creates bans entry with no stream_id, adds IP ban.
// 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.
// 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(
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) {
} catch (err) {
console.error('[Mod] Global ban error:', err.message);
console.error('[Mod] Global ban error:', err.message);
res.status(500).json({ error: 'Failed to ban user' });
res.status(500).json({ error: 'Failed to ban user' });
@@ -138,60 +155,11 @@ router.post('/global-ban', permissions.requireGlobalMod, (req, res) => {
@@ -138,60 +155,11 @@ router.post('/global-ban', permissions.requireGlobalMod, (req, res) => {
});
});
// ── Per-User Ban/Unban (staff console) ───────────────────────
// ── 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) => {
router.post('/users/:id/ban', permissions.requireGlobalMod, (req, res) => {
try {
try {
- const userId = parseInt(req.params.id);
const { reason, duration_hours } = req.body;
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) {
} catch (err) {
console.error('[Mod] User ban error:', err.message);
console.error('[Mod] User ban error:', err.message);
res.status(500).json({ error: 'Failed to ban user' });
res.status(500).json({ error: 'Failed to ban user' });
@@ -852,10 +820,15 @@ router.post('/ip/ban-all', permissions.requireGlobalMod, (req, res) => {
@@ -852,10 +820,15 @@ router.post('/ip/ban-all', permissions.requireGlobalMod, (req, res) => {
scope_type: 'site',
scope_type: 'site',
actor_user_id: req.user.id,
actor_user_id: req.user.id,
action_type: 'ip_ban_all',
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) {
} catch (err) {
console.error('[Mod] IP ban-all error:', err.message);
console.error('[Mod] IP ban-all error:', err.message);
res.status(500).json({ error: 'Failed to ban IP' });
res.status(500).json({ error: 'Failed to ban IP' });
@@ -214,6 +214,8 @@ router.put('/users/:id', (req, res) => {
@@ -214,6 +214,8 @@ router.put('/users/:id', (req, res) => {
router.post('/users/:id/ban', (req, res) => {
router.post('/users/:id/ban', (req, res) => {
try {
try {
const { reason, duration_hours } = req.body;
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
const expires = duration_hours
? new Date(Date.now() + duration_hours * 3600000).toISOString()
? new Date(Date.now() + duration_hours * 3600000).toISOString()
: null;
: null;
@@ -7906,15 +7906,23 @@ function getIpLog({ userId, anonId, ip, action, limit = 100, offset = 0 } = {})
@@ -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.
* Ban all accounts sharing an IP. Returns the list of user IDs banned.
*/
*/
function banAllAccountsOnIp(ip, { reason, bannedBy, expires }) {
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(`
const users = all(`
- SELECT DISTINCT il.user_id
+ SELECT DISTINCT il.user_id, u.role
FROM ip_log il
FROM ip_log il
+ JOIN users u ON u.id = il.user_id
WHERE il.ip_address = ? AND il.user_id IS NOT NULL
WHERE il.ip_address = ? AND il.user_id IS NOT NULL
`, [ip]);
`, [ip]);
const bannedIds = [];
const bannedIds = [];
+ const skippedStaff = [];
for (const row of users) {
for (const row of users) {
if (!row.user_id) continue;
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
// Set is_banned flag
run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ? AND is_banned = 0', [reason, row.user_id]);
run('UPDATE users SET is_banned = 1, ban_reason = ? WHERE id = ? AND is_banned = 0', [reason, row.user_id]);
// Create global user ban
// Create global user ban
@@ -7926,6 +7934,7 @@ function banAllAccountsOnIp(ip, { reason, bannedBy, expires }) {
@@ -7926,6 +7934,7 @@ function banAllAccountsOnIp(ip, { reason, bannedBy, expires }) {
run(`INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
run(`INSERT INTO bans (ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)`,
[ip, reason, bannedBy, expires || null]);
[ip, reason, bannedBy, expires || null]);
+ bannedIds.skippedStaff = skippedStaff;
return bannedIds;
return bannedIds;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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`);