Skip to content

Commit 42ed39e

Browse files
authored
feat(admin): moderation tools for limits and activity stats (#941)
1 parent 9c736c8 commit 42ed39e

29 files changed

Lines changed: 1883 additions & 0 deletions

‎.changeset/admin-moderation-634.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(admin): moderation tools for pubkey, kind, and IP limits (closes #634)
6+
7+
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.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
(() => {
2+
const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' }
3+
const adminBase = `${config.pathPrefix || ''}/admin`
4+
5+
const syncLine = document.getElementById('moderation-sync')
6+
if (!syncLine) {
7+
return
8+
}
9+
10+
const elements = {
11+
error: document.getElementById('moderation-error'),
12+
success: document.getElementById('moderation-success'),
13+
syncLine,
14+
banPubkeyInput: document.getElementById('moderation-ban-pubkey'),
15+
ipInput: document.getElementById('moderation-ip'),
16+
banButton: document.getElementById('moderation-ban-button'),
17+
unbanButton: document.getElementById('moderation-unban-button'),
18+
whitelistIpButton: document.getElementById('moderation-whitelist-ip-button'),
19+
unwhitelistIpButton: document.getElementById('moderation-unwhitelist-ip-button'),
20+
refreshButton: document.getElementById('moderation-refresh-button'),
21+
topPubkeysBody: document.getElementById('moderation-top-pubkeys-body'),
22+
topIpsBody: document.getElementById('moderation-top-ips-body'),
23+
ratePubkeysBody: document.getElementById('moderation-rate-pubkeys-body'),
24+
pubkeyBlacklist: document.getElementById('moderation-pubkey-blacklist'),
25+
pubkeyWhitelist: document.getElementById('moderation-pubkey-whitelist'),
26+
kindBlacklist: document.getElementById('moderation-kind-blacklist'),
27+
kindWhitelist: document.getElementById('moderation-kind-whitelist'),
28+
bypassPubkeys: document.getElementById('moderation-bypass-pubkeys'),
29+
bypassIps: document.getElementById('moderation-bypass-ips'),
30+
}
31+
32+
const escapeHtml = (value) =>
33+
String(value)
34+
.replaceAll('&', '&')
35+
.replaceAll('<', '&lt;')
36+
.replaceAll('>', '&gt;')
37+
.replaceAll('"', '&quot;')
38+
.replaceAll("'", '&#39;')
39+
40+
const adminFetch = async (path, options = {}) => {
41+
const response = await fetch(`${adminBase}${path}`, {
42+
credentials: 'same-origin',
43+
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
44+
...options,
45+
})
46+
if (response.status === 401) {
47+
throw new Error('Unauthorized')
48+
}
49+
return response
50+
}
51+
52+
const showError = (message) => {
53+
elements.error.textContent = message
54+
elements.error.classList.remove('d-none')
55+
elements.success.classList.add('d-none')
56+
}
57+
58+
const showSuccess = (message) => {
59+
elements.success.textContent = message
60+
elements.success.classList.remove('d-none')
61+
elements.error.classList.add('d-none')
62+
}
63+
64+
const hideMessages = () => {
65+
elements.error.classList.add('d-none')
66+
elements.success.classList.add('d-none')
67+
}
68+
69+
const handleUnauthorized = () => {
70+
globalThis.dispatchEvent(new CustomEvent('admin-unauthorized'))
71+
}
72+
73+
const renderRows = (body, rows, keyField, valueField, emptyLabel) => {
74+
if (!body) {
75+
return
76+
}
77+
if (!Array.isArray(rows) || !rows.length) {
78+
body.innerHTML = `<tr><td colspan="2" class="admin-muted small">${escapeHtml(emptyLabel)}</td></tr>`
79+
return
80+
}
81+
body.innerHTML = rows
82+
.map(
83+
(row) =>
84+
`<tr><td><code class="small">${escapeHtml(row[keyField])}</code></td><td>${escapeHtml(row[valueField])}</td></tr>`,
85+
)
86+
.join('')
87+
}
88+
89+
const renderRateRows = (body, rows) => {
90+
if (!body) {
91+
return
92+
}
93+
if (!Array.isArray(rows) || !rows.length) {
94+
body.innerHTML = '<tr><td colspan="2" class="admin-muted small">No rate-limit hits recorded yet.</td></tr>'
95+
return
96+
}
97+
body.innerHTML = rows
98+
.map((row) => `<tr><td><code class="small">${escapeHtml(row.key)}</code></td><td>${escapeHtml(row.hits)}</td></tr>`)
99+
.join('')
100+
}
101+
102+
const renderLimits = (limits) => {
103+
const pretty = (value) => JSON.stringify(value ?? [], null, 2)
104+
elements.pubkeyBlacklist.textContent = pretty(limits.pubkey?.blacklist)
105+
elements.pubkeyWhitelist.textContent = pretty(limits.pubkey?.whitelist)
106+
elements.kindBlacklist.textContent = pretty(limits.kind?.blacklist)
107+
elements.kindWhitelist.textContent = pretty(limits.kind?.whitelist)
108+
elements.bypassPubkeys.textContent = pretty(limits.rateLimitBypass?.pubkeys)
109+
elements.bypassIps.textContent = pretty(limits.rateLimitBypass?.ipAddresses)
110+
}
111+
112+
const load = async () => {
113+
hideMessages()
114+
try {
115+
const [limitsResponse, statsResponse] = await Promise.all([
116+
adminFetch('/moderation/limits'),
117+
adminFetch('/moderation/stats?windowHours=168&limit=15'),
118+
])
119+
120+
if (!limitsResponse.ok || !statsResponse.ok) {
121+
throw new Error('Failed to load moderation data')
122+
}
123+
124+
const limitsPayload = await limitsResponse.json()
125+
const statsPayload = await statsResponse.json()
126+
renderLimits(limitsPayload.limits)
127+
renderRows(elements.topPubkeysBody, statsPayload.topPublishingPubkeys, 'pubkey', 'eventCount', 'No events in window.')
128+
renderRows(elements.topIpsBody, statsPayload.topClientIps, 'ip', 'eventCount', 'No client IPs in window.')
129+
renderRateRows(elements.ratePubkeysBody, statsPayload.rateLimitHits?.pubkeys)
130+
elements.syncLine.innerHTML = `<span class="prompt-char">&gt;</span> moderation: updated ${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}`
131+
} catch (error) {
132+
if (error instanceof Error && error.message === 'Unauthorized') {
133+
handleUnauthorized()
134+
return
135+
}
136+
showError('Unable to load moderation data.')
137+
}
138+
}
139+
140+
const postAction = async (body) => {
141+
hideMessages()
142+
const response = await adminFetch('/moderation/actions', { method: 'POST', body: JSON.stringify(body) })
143+
const payload = await response.json().catch(() => ({}))
144+
if (!response.ok) {
145+
throw new Error(payload.error || 'Moderation action failed')
146+
}
147+
renderLimits(payload.limits)
148+
showSuccess('Limits updated.')
149+
elements.syncLine.innerHTML = `<span class="prompt-char">&gt;</span> moderation: action ${body.action} applied`
150+
void load()
151+
}
152+
153+
const bindAction = (button, handler) => {
154+
button?.addEventListener('click', () => {
155+
void handler().catch((error) => {
156+
if (error instanceof Error && error.message === 'Unauthorized') {
157+
handleUnauthorized()
158+
return
159+
}
160+
showError(error instanceof Error ? error.message : 'Action failed.')
161+
})
162+
})
163+
}
164+
165+
bindAction(elements.banButton, async () => {
166+
const pubkey = elements.banPubkeyInput?.value?.trim()
167+
if (!pubkey) {
168+
throw new Error('Enter a pubkey or npub to ban.')
169+
}
170+
await postAction({ action: 'ban-pubkey', pubkey })
171+
})
172+
173+
bindAction(elements.unbanButton, async () => {
174+
const pubkey = elements.banPubkeyInput?.value?.trim()
175+
if (!pubkey) {
176+
throw new Error('Enter a pubkey or npub to unban.')
177+
}
178+
await postAction({ action: 'unban-pubkey', pubkey })
179+
})
180+
181+
bindAction(elements.whitelistIpButton, async () => {
182+
const ip = elements.ipInput?.value?.trim()
183+
if (!ip) {
184+
throw new Error('Enter an IP address.')
185+
}
186+
await postAction({ action: 'whitelist-ip', ip })
187+
})
188+
189+
bindAction(elements.unwhitelistIpButton, async () => {
190+
const ip = elements.ipInput?.value?.trim()
191+
if (!ip) {
192+
throw new Error('Enter an IP address.')
193+
}
194+
await postAction({ action: 'unwhitelist-ip', ip })
195+
})
196+
197+
elements.refreshButton?.addEventListener('click', () => {
198+
void load()
199+
})
200+
201+
window.__ADMIN_MODERATION__ = { load }
202+
})()

‎resources/admin/assets/dashboard.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,6 +1571,14 @@
15711571
return
15721572
}
15731573

1574+
if (viewId === 'moderation-view') {
1575+
stopNetworkHealthPolling()
1576+
stopMetricsStream()
1577+
window.__ADMIN_OPS__?.stop?.()
1578+
window.__ADMIN_MODERATION__?.load?.()
1579+
return
1580+
}
1581+
15741582
if (viewId === 'metrics-view') {
15751583
stopNetworkHealthPolling()
15761584
window.__ADMIN_OPS__?.stop?.()

‎resources/admin/dashboard.html‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ <h2 class="panel-title h6 mb-2">Sign in</h2>
6767
<button type="button" class="nav-item active" data-view="metrics-view">Metrics</button>
6868
<button type="button" class="nav-item" data-view="operations-view">Operations</button>
6969
<button type="button" class="nav-item" data-view="trust-view">Trust</button>
70+
<button type="button" class="nav-item" data-view="moderation-view">Moderation</button>
7071
<button type="button" class="nav-item" data-view="settings-view">Settings</button>
7172
<button type="button" class="nav-item" data-view="notifications-view">Notifications</button>
7273
</aside>
@@ -524,6 +525,108 @@ <h2 class="section-title mb-0">Admin audit log</h2>
524525
</section>
525526
</div>
526527

528+
<div id="moderation-view" class="dashboard-view d-none">
529+
<section class="dashboard-section">
530+
<h2 class="section-title">Quick actions</h2>
531+
<p class="admin-muted small">Ban or unban pubkeys and manage event rate-limit bypass lists without editing YAML by hand.</p>
532+
<div id="moderation-error" class="alert alert-danger d-none small" role="alert"></div>
533+
<div id="moderation-success" class="alert settings-success d-none small" role="status"></div>
534+
<div class="row g-2 mb-3">
535+
<div class="col-md-6 col-lg-4">
536+
<label class="form-label field-label small" for="moderation-ban-pubkey">Pubkey or npub</label>
537+
<input id="moderation-ban-pubkey" class="form-control console-input form-control-sm" placeholder="hex or npub">
538+
</div>
539+
<div class="col-md-6 col-lg-4 d-flex align-items-end gap-2 flex-wrap">
540+
<button id="moderation-ban-button" type="button" class="btn btn-console btn-sm">Ban pubkey</button>
541+
<button id="moderation-unban-button" type="button" class="btn btn-console btn-sm">Unban</button>
542+
</div>
543+
</div>
544+
<div class="row g-2 mb-2">
545+
<div class="col-md-6 col-lg-4">
546+
<label class="form-label field-label small" for="moderation-ip">Client IP</label>
547+
<input id="moderation-ip" class="form-control console-input form-control-sm" placeholder="203.0.113.10">
548+
</div>
549+
<div class="col-md-6 col-lg-4 d-flex align-items-end gap-2 flex-wrap">
550+
<button id="moderation-whitelist-ip-button" type="button" class="btn btn-console btn-sm">Bypass rate limits</button>
551+
<button id="moderation-unwhitelist-ip-button" type="button" class="btn btn-console btn-sm">Remove bypass</button>
552+
</div>
553+
</div>
554+
<p id="moderation-sync" class="sync-line mb-0"><span class="prompt-char">&gt;</span> moderation: not loaded</p>
555+
</section>
556+
557+
<section class="dashboard-section">
558+
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
559+
<h2 class="section-title mb-0">Activity (7 days)</h2>
560+
<button id="moderation-refresh-button" type="button" class="btn btn-console btn-sm">Refresh</button>
561+
</div>
562+
<div class="row g-3">
563+
<div class="col-lg-4">
564+
<h3 class="section-title h6">Top publishing pubkeys</h3>
565+
<div class="table-responsive panel-card">
566+
<table class="table table-sm mb-0">
567+
<thead><tr><th scope="col">Pubkey</th><th scope="col">Events</th></tr></thead>
568+
<tbody id="moderation-top-pubkeys-body">
569+
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
570+
</tbody>
571+
</table>
572+
</div>
573+
</div>
574+
<div class="col-lg-4">
575+
<h3 class="section-title h6">Top client IPs</h3>
576+
<div class="table-responsive panel-card">
577+
<table class="table table-sm mb-0">
578+
<thead><tr><th scope="col">IP</th><th scope="col">Events</th></tr></thead>
579+
<tbody id="moderation-top-ips-body">
580+
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
581+
</tbody>
582+
</table>
583+
</div>
584+
</div>
585+
<div class="col-lg-4">
586+
<h3 class="section-title h6">Rate-limit hits</h3>
587+
<div class="table-responsive panel-card">
588+
<table class="table table-sm mb-0">
589+
<thead><tr><th scope="col">Pubkey</th><th scope="col">Hits</th></tr></thead>
590+
<tbody id="moderation-rate-pubkeys-body">
591+
<tr><td colspan="2" class="admin-muted small">Not loaded.</td></tr>
592+
</tbody>
593+
</table>
594+
</div>
595+
</div>
596+
</div>
597+
</section>
598+
599+
<section class="dashboard-section">
600+
<h2 class="section-title">Current limits</h2>
601+
<div class="row g-3">
602+
<div class="col-lg-6">
603+
<h3 class="section-title h6">Pubkey blacklist</h3>
604+
<pre id="moderation-pubkey-blacklist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
605+
</div>
606+
<div class="col-lg-6">
607+
<h3 class="section-title h6">Pubkey whitelist</h3>
608+
<pre id="moderation-pubkey-whitelist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
609+
</div>
610+
<div class="col-lg-6">
611+
<h3 class="section-title h6">Kind blacklist</h3>
612+
<pre id="moderation-kind-blacklist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
613+
</div>
614+
<div class="col-lg-6">
615+
<h3 class="section-title h6">Kind whitelist</h3>
616+
<pre id="moderation-kind-whitelist" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
617+
</div>
618+
<div class="col-lg-6">
619+
<h3 class="section-title h6">Rate-limit bypass pubkeys</h3>
620+
<pre id="moderation-bypass-pubkeys" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
621+
</div>
622+
<div class="col-lg-6">
623+
<h3 class="section-title h6">Rate-limit bypass IPs</h3>
624+
<pre id="moderation-bypass-ips" class="panel-card small admin-muted mb-0" style="max-height: 160px; overflow: auto;"></pre>
625+
</div>
626+
</div>
627+
</section>
628+
</div>
629+
527630
<div id="settings-view" class="dashboard-view d-none">
528631
<section class="dashboard-section">
529632
<h2 class="section-title">Configuration<span class="sync-cursor" aria-hidden="true"></span></h2>
@@ -572,5 +675,6 @@ <h3 class="settings-diff-heading">Preview diff</h3>
572675
<script src="{{path_prefix}}/admin/assets/notifications.js" nonce="{{nonce}}"></script>
573676
<script src="{{path_prefix}}/admin/assets/admin-ops.js" nonce="{{nonce}}"></script>
574677
<script src="{{path_prefix}}/admin/assets/admin-trust.js" nonce="{{nonce}}"></script>
678+
<script src="{{path_prefix}}/admin/assets/admin-moderation.js" nonce="{{nonce}}"></script>
575679
</body>
576680
</html>

‎src/@types/admin-audit.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type AdminAuditAction =
66
| 'notifications.updated'
77
| 'probe.manual_run'
88
| 'nip05.reverify'
9+
| 'moderation.limits.updated'
10+
| 'moderation.action'
911

1012
export interface AdminAuditEntry {
1113
id: string
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Request, Response } from 'express'
2+
3+
import { IController } from '../../@types/controllers'
4+
import { readAdminModerationLimits } from '../../utils/admin-moderation'
5+
6+
export class GetAdminModerationLimitsController implements IController {
7+
public async handleRequest(_request: Request, response: Response): Promise<void> {
8+
response.status(200).setHeader('content-type', 'application/json').send({
9+
limits: readAdminModerationLimits(),
10+
})
11+
}
12+
}

0 commit comments

Comments
 (0)