Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/admin-moderation-634.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nostream": minor
---

feat(admin): moderation tools for pubkey, kind, and IP limits (closes #634)

Adds admin API and console view to read and update event pubkey/kind lists, rate-limit bypass lists, quick ban/unban actions, and activity stats for top publishers, client IPs, and rate-limit hits.
202 changes: 202 additions & 0 deletions resources/admin/assets/admin-moderation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
(() => {
const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' }
const adminBase = `${config.pathPrefix || ''}/admin`

const syncLine = document.getElementById('moderation-sync')
if (!syncLine) {
return
}

const elements = {
error: document.getElementById('moderation-error'),
success: document.getElementById('moderation-success'),
syncLine,
banPubkeyInput: document.getElementById('moderation-ban-pubkey'),
ipInput: document.getElementById('moderation-ip'),
banButton: document.getElementById('moderation-ban-button'),
unbanButton: document.getElementById('moderation-unban-button'),
whitelistIpButton: document.getElementById('moderation-whitelist-ip-button'),
unwhitelistIpButton: document.getElementById('moderation-unwhitelist-ip-button'),
refreshButton: document.getElementById('moderation-refresh-button'),
topPubkeysBody: document.getElementById('moderation-top-pubkeys-body'),
topIpsBody: document.getElementById('moderation-top-ips-body'),
ratePubkeysBody: document.getElementById('moderation-rate-pubkeys-body'),
pubkeyBlacklist: document.getElementById('moderation-pubkey-blacklist'),
pubkeyWhitelist: document.getElementById('moderation-pubkey-whitelist'),
kindBlacklist: document.getElementById('moderation-kind-blacklist'),
kindWhitelist: document.getElementById('moderation-kind-whitelist'),
bypassPubkeys: document.getElementById('moderation-bypass-pubkeys'),
bypassIps: document.getElementById('moderation-bypass-ips'),
}

const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')

const adminFetch = async (path, options = {}) => {
const response = await fetch(`${adminBase}${path}`, {
credentials: 'same-origin',
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
...options,
})
if (response.status === 401) {
throw new Error('Unauthorized')
}
return response
}

const showError = (message) => {
elements.error.textContent = message
elements.error.classList.remove('d-none')
elements.success.classList.add('d-none')
}

const showSuccess = (message) => {
elements.success.textContent = message
elements.success.classList.remove('d-none')
elements.error.classList.add('d-none')
}

const hideMessages = () => {
elements.error.classList.add('d-none')
elements.success.classList.add('d-none')
}

const handleUnauthorized = () => {
globalThis.dispatchEvent(new CustomEvent('admin-unauthorized'))
}

const renderRows = (body, rows, keyField, valueField, emptyLabel) => {
if (!body) {
return
}
if (!Array.isArray(rows) || !rows.length) {
body.innerHTML = `<tr><td colspan="2" class="admin-muted small">${escapeHtml(emptyLabel)}</td></tr>`
return
}
body.innerHTML = rows
.map(
(row) =>
`<tr><td><code class="small">${escapeHtml(row[keyField])}</code></td><td>${escapeHtml(row[valueField])}</td></tr>`,
)
.join('')
}

const renderRateRows = (body, rows) => {
if (!body) {
return
}
if (!Array.isArray(rows) || !rows.length) {
body.innerHTML = '<tr><td colspan="2" class="admin-muted small">No rate-limit hits recorded yet.</td></tr>'
return
}
body.innerHTML = rows
.map((row) => `<tr><td><code class="small">${escapeHtml(row.key)}</code></td><td>${escapeHtml(row.hits)}</td></tr>`)
.join('')
}

const renderLimits = (limits) => {
const pretty = (value) => JSON.stringify(value ?? [], null, 2)
elements.pubkeyBlacklist.textContent = pretty(limits.pubkey?.blacklist)
elements.pubkeyWhitelist.textContent = pretty(limits.pubkey?.whitelist)
elements.kindBlacklist.textContent = pretty(limits.kind?.blacklist)
elements.kindWhitelist.textContent = pretty(limits.kind?.whitelist)
elements.bypassPubkeys.textContent = pretty(limits.rateLimitBypass?.pubkeys)
elements.bypassIps.textContent = pretty(limits.rateLimitBypass?.ipAddresses)
}

const load = async () => {
hideMessages()
try {
const [limitsResponse, statsResponse] = await Promise.all([
adminFetch('/moderation/limits'),
adminFetch('/moderation/stats?windowHours=168&limit=15'),
])

if (!limitsResponse.ok || !statsResponse.ok) {
throw new Error('Failed to load moderation data')
}

const limitsPayload = await limitsResponse.json()
const statsPayload = await statsResponse.json()
renderLimits(limitsPayload.limits)
renderRows(elements.topPubkeysBody, statsPayload.topPublishingPubkeys, 'pubkey', 'eventCount', 'No events in window.')
renderRows(elements.topIpsBody, statsPayload.topClientIps, 'ip', 'eventCount', 'No client IPs in window.')
renderRateRows(elements.ratePubkeysBody, statsPayload.rateLimitHits?.pubkeys)
elements.syncLine.innerHTML = `<span class="prompt-char">&gt;</span> moderation: updated ${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}`
} catch (error) {
if (error instanceof Error && error.message === 'Unauthorized') {
handleUnauthorized()
return
}
showError('Unable to load moderation data.')
}
}

const postAction = async (body) => {
hideMessages()
const response = await adminFetch('/moderation/actions', { method: 'POST', body: JSON.stringify(body) })
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload.error || 'Moderation action failed')
}
renderLimits(payload.limits)
showSuccess('Limits updated.')
elements.syncLine.innerHTML = `<span class="prompt-char">&gt;</span> moderation: action ${body.action} applied`
void load()
}

const bindAction = (button, handler) => {
button?.addEventListener('click', () => {
void handler().catch((error) => {
if (error instanceof Error && error.message === 'Unauthorized') {
handleUnauthorized()
return
}
showError(error instanceof Error ? error.message : 'Action failed.')
})
})
}

bindAction(elements.banButton, async () => {
const pubkey = elements.banPubkeyInput?.value?.trim()
if (!pubkey) {
throw new Error('Enter a pubkey or npub to ban.')
}
await postAction({ action: 'ban-pubkey', pubkey })
})

bindAction(elements.unbanButton, async () => {
const pubkey = elements.banPubkeyInput?.value?.trim()
if (!pubkey) {
throw new Error('Enter a pubkey or npub to unban.')
}
await postAction({ action: 'unban-pubkey', pubkey })
})

bindAction(elements.whitelistIpButton, async () => {
const ip = elements.ipInput?.value?.trim()
if (!ip) {
throw new Error('Enter an IP address.')
}
await postAction({ action: 'whitelist-ip', ip })
})

bindAction(elements.unwhitelistIpButton, async () => {
const ip = elements.ipInput?.value?.trim()
if (!ip) {
throw new Error('Enter an IP address.')
}
await postAction({ action: 'unwhitelist-ip', ip })
})

elements.refreshButton?.addEventListener('click', () => {
void load()
})

window.__ADMIN_MODERATION__ = { load }
})()
8 changes: 8 additions & 0 deletions resources/admin/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,14 @@
return
}

if (viewId === 'moderation-view') {
stopNetworkHealthPolling()
stopMetricsStream()
window.__ADMIN_OPS__?.stop?.()
window.__ADMIN_MODERATION__?.load?.()
return
}

if (viewId === 'metrics-view') {
stopNetworkHealthPolling()
window.__ADMIN_OPS__?.stop?.()
Expand Down
104 changes: 104 additions & 0 deletions resources/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ <h2 class="panel-title h6 mb-2">Sign in</h2>
<button type="button" class="nav-item active" data-view="metrics-view">Metrics</button>
<button type="button" class="nav-item" data-view="operations-view">Operations</button>
<button type="button" class="nav-item" data-view="trust-view">Trust</button>
<button type="button" class="nav-item" data-view="moderation-view">Moderation</button>
<button type="button" class="nav-item" data-view="settings-view">Settings</button>
<button type="button" class="nav-item" data-view="notifications-view">Notifications</button>
</aside>
Expand Down Expand Up @@ -524,6 +525,108 @@ <h2 class="section-title mb-0">Admin audit log</h2>
</section>
</div>

<div id="moderation-view" class="dashboard-view d-none">
<section class="dashboard-section">
<h2 class="section-title">Quick actions</h2>
<p class="admin-muted small">Ban or unban pubkeys and manage event rate-limit bypass lists without editing YAML by hand.</p>
<div id="moderation-error" class="alert alert-danger d-none small" role="alert"></div>
<div id="moderation-success" class="alert settings-success d-none small" role="status"></div>
<div class="row g-2 mb-3">
<div class="col-md-6 col-lg-4">
<label class="form-label field-label small" for="moderation-ban-pubkey">Pubkey or npub</label>
<input id="moderation-ban-pubkey" class="form-control console-input form-control-sm" placeholder="hex or npub">
</div>
<div class="col-md-6 col-lg-4 d-flex align-items-end gap-2 flex-wrap">
<button id="moderation-ban-button" type="button" class="btn btn-console btn-sm">Ban pubkey</button>
<button id="moderation-unban-button" type="button" class="btn btn-console btn-sm">Unban</button>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-6 col-lg-4">
<label class="form-label field-label small" for="moderation-ip">Client IP</label>
<input id="moderation-ip" class="form-control console-input form-control-sm" placeholder="203.0.113.10">
</div>
<div class="col-md-6 col-lg-4 d-flex align-items-end gap-2 flex-wrap">
<button id="moderation-whitelist-ip-button" type="button" class="btn btn-console btn-sm">Bypass rate limits</button>
<button id="moderation-unwhitelist-ip-button" type="button" class="btn btn-console btn-sm">Remove bypass</button>
</div>
</div>
<p id="moderation-sync" class="sync-line mb-0"><span class="prompt-char">&gt;</span> moderation: not loaded</p>
</section>

<section class="dashboard-section">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h2 class="section-title mb-0">Activity (7 days)</h2>
<button id="moderation-refresh-button" type="button" class="btn btn-console btn-sm">Refresh</button>
</div>
<div class="row g-3">
<div class="col-lg-4">
<h3 class="section-title h6">Top publishing pubkeys</h3>
<div class="table-responsive panel-card">
<table class="table table-sm mb-0">
<thead><tr><th scope="col">Pubkey</th><th scope="col">Events</th></tr></thead>
<tbody id="moderation-top-pubkeys-body">
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-4">
<h3 class="section-title h6">Top client IPs</h3>
<div class="table-responsive panel-card">
<table class="table table-sm mb-0">
<thead><tr><th scope="col">IP</th><th scope="col">Events</th></tr></thead>
<tbody id="moderation-top-ips-body">
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-4">
<h3 class="section-title h6">Rate-limit hits</h3>
<div class="table-responsive panel-card">
<table class="table table-sm mb-0">
<thead><tr><th scope="col">Pubkey</th><th scope="col">Hits</th></tr></thead>
<tbody id="moderation-rate-pubkeys-body">
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>

<section class="dashboard-section">
<h2 class="section-title">Current limits</h2>
<div class="row g-3">
<div class="col-lg-6">
<h3 class="section-title h6">Pubkey blacklist</h3>
<pre id="moderation-pubkey-blacklist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
<div class="col-lg-6">
<h3 class="section-title h6">Pubkey whitelist</h3>
<pre id="moderation-pubkey-whitelist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
<div class="col-lg-6">
<h3 class="section-title h6">Kind blacklist</h3>
<pre id="moderation-kind-blacklist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
<div class="col-lg-6">
<h3 class="section-title h6">Kind whitelist</h3>
<pre id="moderation-kind-whitelist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
<div class="col-lg-6">
<h3 class="section-title h6">Rate-limit bypass pubkeys</h3>
<pre id="moderation-bypass-pubkeys" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
<div class="col-lg-6">
<h3 class="section-title h6">Rate-limit bypass IPs</h3>
<pre id="moderation-bypass-ips" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
</div>
</div>
</section>
</div>

<div id="settings-view" class="dashboard-view d-none">
<section class="dashboard-section">
<h2 class="section-title">Configuration<span class="sync-cursor" aria-hidden="true"></span></h2>
Expand Down Expand Up @@ -572,5 +675,6 @@ <h3 class="settings-diff-heading">Preview diff</h3>
<script src="{{path_prefix}}/admin/assets/notifications.js" nonce="{{nonce}}"></script>
<script src="{{path_prefix}}/admin/assets/admin-ops.js" nonce="{{nonce}}"></script>
<script src="{{path_prefix}}/admin/assets/admin-trust.js" nonce="{{nonce}}"></script>
<script src="{{path_prefix}}/admin/assets/admin-moderation.js" nonce="{{nonce}}"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions src/@types/admin-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type AdminAuditAction =
| 'notifications.updated'
| 'probe.manual_run'
| 'nip05.reverify'
| 'moderation.limits.updated'
| 'moderation.action'

export interface AdminAuditEntry {
id: string
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/admin/get-moderation-limits-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { readAdminModerationLimits } from '../../utils/admin-moderation'

export class GetAdminModerationLimitsController implements IController {
public async handleRequest(_request: Request, response: Response): Promise<void> {
response.status(200).setHeader('content-type', 'application/json').send({
limits: readAdminModerationLimits(),
})
}
}
Loading
Loading