Compare commits

..

6 Commits

Author SHA1 Message Date
woikos
91e38edd2c docs: add NIP specification for curation mode (v0.50.1)
Some checks failed
Go / build-and-release (push) Failing after 5s
- Add NIP-CURATION.md documenting the relay curation system
- Covers kind 30078 configuration event structure
- Documents three-tier publisher classification (trusted/blacklisted/unclassified)
- Specifies rate limiting and IP flood protection
- Lists NIP-86 management API methods
- Includes kind categories and event processing flow

Files modified:
- docs/NIP-CURATION.md: New NIP specification for curation mode
- pkg/version/version: Bump to v0.50.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:25:39 +01:00
woikos
cb50a9c5c4 feat(curating): add user event viewer and delete functionality (v0.50.0)
Some checks failed
Go / build-and-release (push) Failing after 4s
- Add geteventsforpubkey API method for viewing user events with pagination
- Add deleteeventsforpubkey API method to purge blacklisted user events
- Add clickable user detail view in curation UI showing all events
- Add event content expansion/truncation for long content
- Add kind name display for common Nostr event types
- Implement safety check requiring blacklist before event deletion

Files modified:
- app/handle-nip86-curating.go: Add event fetch/delete handlers
- pkg/database/curating-acl.go: Add GetEventsForPubkey, DeleteEventsForPubkey
- app/web/src/CurationView.svelte: Add user detail view with event listing
- pkg/version/version: Bump to v0.50.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:19:33 +01:00
woikos
c5be98bcaa fix(curating): correct pubkey hash computation and UI field name
- Fix countEventsForPubkey to use SHA256 hash of pubkey (first 8 bytes)
  matching the PubHash type used in the Pubkey index
- Fix UI to use event_count field instead of total_events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:40:31 +01:00
woikos
417866ebf4 feat(curating-ui): add Scan Database button to unclassified users tab
- Adds "Scan Database" button that calls the scanpubkeys API
- Shows results with total pubkeys, events, and skipped count
- Automatically refreshes the unclassified users list after scan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:33:43 +01:00
woikos
0e87337723 feat(curating): add scanpubkeys API method to retroactively populate unclassified users
- Add ScanAllPubkeys method to scan SerialPubkey index for all pubkeys
- Count events for each pubkey using the Pubkey index
- Store event counts in CURATING_ACL_EVENT_COUNT_ prefix
- Add NIP-86 "scanpubkeys" API endpoint to trigger the scan

This allows the curation UI to show all existing users in the unclassified
list, even if they had events before curating mode was enabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:26:31 +01:00
woikos
b10851c209 fix: add frontend-compatible aliases in getcuratingconfig response
The frontend expected 'categories' and 'custom_kinds' but the backend
returned 'kind_categories' and 'allowed_kinds'. Add aliases for both
naming conventions to ensure frontend compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:18:31 +01:00
8 changed files with 1198 additions and 50 deletions

View File

@@ -143,6 +143,12 @@ func (s *Server) handleCuratingNIP86Method(request NIP86Request, curatingACL *ac
return s.handleUnblockCuratingIP(request.Params, dbACL)
case "isconfigured":
return s.handleIsConfigured(dbACL)
case "scanpubkeys":
return s.handleScanPubkeys(dbACL)
case "geteventsforpubkey":
return s.handleGetEventsForPubkey(request.Params, dbACL)
case "deleteeventsforpubkey":
return s.handleDeleteEventsForPubkey(request.Params, dbACL)
default:
return NIP86Response{Error: "Unknown method: " + request.Method}
}
@@ -167,6 +173,9 @@ func (s *Server) handleCuratingSupportedMethods() NIP86Response {
"listblockedips",
"unblockip",
"isconfigured",
"scanpubkeys",
"geteventsforpubkey",
"deleteeventsforpubkey",
}
return NIP86Response{Result: methods}
}
@@ -444,8 +453,11 @@ func (s *Server) handleGetCuratingConfig(dbACL *database.CuratingACL) NIP86Respo
"first_ban_hours": config.FirstBanHours,
"second_ban_hours": config.SecondBanHours,
"allowed_kinds": config.AllowedKinds,
"custom_kinds": config.AllowedKinds, // Alias for frontend compatibility
"allowed_ranges": config.AllowedRanges,
"kind_ranges": config.AllowedRanges, // Alias for frontend compatibility
"kind_categories": config.KindCategories,
"categories": config.KindCategories, // Alias for frontend compatibility
"config_event_id": config.ConfigEventID,
"config_pubkey": config.ConfigPubkey,
"configured_at": config.ConfiguredAt,
@@ -603,3 +615,122 @@ func parseRange(s string, parts []int) (int, error) {
}
return 0, nil
}
// handleScanPubkeys scans the database for all pubkeys and populates event counts
// This is used to retroactively populate the unclassified users list
func (s *Server) handleScanPubkeys(dbACL *database.CuratingACL) NIP86Response {
result, err := dbACL.ScanAllPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to scan pubkeys: " + err.Error()}
}
return NIP86Response{Result: map[string]interface{}{
"total_pubkeys": result.TotalPubkeys,
"total_events": result.TotalEvents,
"skipped": result.Skipped,
}}
}
// handleGetEventsForPubkey returns events for a specific pubkey
// Params: [pubkey, limit (optional, default 100), offset (optional, default 0)]
func (s *Server) handleGetEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Parse optional limit (default 100)
limit := 100
if len(params) > 1 {
if l, ok := params[1].(float64); ok {
limit = int(l)
if limit > 500 {
limit = 500 // Cap at 500
}
if limit < 1 {
limit = 1
}
}
}
// Parse optional offset (default 0)
offset := 0
if len(params) > 2 {
if o, ok := params[2].(float64); ok {
offset = int(o)
if offset < 0 {
offset = 0
}
}
}
events, total, err := dbACL.GetEventsForPubkey(pubkey, limit, offset)
if chk.E(err) {
return NIP86Response{Error: "Failed to get events: " + err.Error()}
}
// Convert to response format
eventList := make([]map[string]interface{}, len(events))
for i, ev := range events {
eventList[i] = map[string]interface{}{
"id": ev.ID,
"kind": ev.Kind,
"content": ev.Content,
"created_at": ev.CreatedAt,
}
}
return NIP86Response{Result: map[string]interface{}{
"events": eventList,
"total": total,
"limit": limit,
"offset": offset,
}}
}
// handleDeleteEventsForPubkey deletes all events for a specific pubkey
// This is only allowed for blacklisted pubkeys as a safety measure
// Params: [pubkey]
func (s *Server) handleDeleteEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Safety check: only allow deletion of events from blacklisted users
isBlacklisted, err := dbACL.IsPubkeyBlacklisted(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to check blacklist status: " + err.Error()}
}
if !isBlacklisted {
return NIP86Response{Error: "Can only delete events from blacklisted users. Blacklist the user first."}
}
// Delete all events for this pubkey
deleted, err := dbACL.DeleteEventsForPubkey(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to delete events: " + err.Error()}
}
return NIP86Response{Result: map[string]interface{}{
"deleted": deleted,
"pubkey": pubkey,
}}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,15 @@
let messageType = "info";
let isConfigured = false;
// User detail view state
let selectedUser = null;
let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
let userEvents = [];
let userEventsTotal = 0;
let userEventsOffset = 0;
let loadingEvents = false;
let expandedEvents = {}; // Track which events are expanded
// Configuration state
let config = {
daily_limit: 50,
@@ -186,6 +195,19 @@
}
}
// Scan database for all pubkeys
async function scanDatabase() {
try {
const result = await callNIP86API("scanpubkeys");
showMessage(`Database scanned: ${result.total_pubkeys} pubkeys, ${result.total_events} events (${result.skipped} skipped)`, "success");
// Refresh the unclassified users list
await loadUnclassifiedUsers();
} catch (error) {
console.error("Failed to scan database:", error);
showMessage("Failed to scan database: " + error.message, "error");
}
}
// Load spam events
async function loadSpamEvents() {
try {
@@ -430,6 +452,176 @@
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
}
// Show message helper
function showMessage(msg, type = "info") {
message = msg;
messageType = type;
}
// Open user detail view
async function openUserDetail(pubkey, type) {
console.log("openUserDetail called:", pubkey, type);
selectedUser = pubkey;
selectedUserType = type;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
console.log("selectedUser set to:", selectedUser);
await loadUserEvents();
}
// Close user detail view
function closeUserDetail() {
selectedUser = null;
selectedUserType = null;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
}
// Load events for selected user
async function loadUserEvents() {
console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
if (!selectedUser || loadingEvents) return;
try {
loadingEvents = true;
console.log("Calling geteventsforpubkey API...");
const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
console.log("API result:", result);
if (result) {
if (userEventsOffset === 0) {
userEvents = result.events || [];
} else {
userEvents = [...userEvents, ...(result.events || [])];
}
userEventsTotal = result.total || 0;
}
} catch (error) {
console.error("Failed to load user events:", error);
showMessage("Failed to load events: " + error.message, "error");
} finally {
loadingEvents = false;
}
}
// Load more events
async function loadMoreEvents() {
userEventsOffset = userEvents.length;
await loadUserEvents();
}
// Toggle event expansion
function toggleEventExpansion(eventId) {
expandedEvents = {
...expandedEvents,
[eventId]: !expandedEvents[eventId]
};
}
// Truncate content to 6 lines (approximately 300 chars per line)
function truncateContent(content, maxLines = 6) {
if (!content) return "";
const lines = content.split('\n');
if (lines.length <= maxLines && content.length <= maxLines * 100) {
return content;
}
// Truncate by lines or characters, whichever is smaller
let truncated = lines.slice(0, maxLines).join('\n');
if (truncated.length > maxLines * 100) {
truncated = truncated.substring(0, maxLines * 100);
}
return truncated;
}
// Check if content is truncated
function isContentTruncated(content, maxLines = 6) {
if (!content) return false;
const lines = content.split('\n');
return lines.length > maxLines || content.length > maxLines * 100;
}
// Trust user from detail view and refresh
async function trustUserFromDetail() {
await trustPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Blacklist user from detail view and refresh
async function blacklistUserFromDetail() {
await blacklistPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Untrust user from detail view and refresh
async function untrustUserFromDetail() {
await untrustPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Unblacklist user from detail view and refresh
async function unblacklistUserFromDetail() {
await unblacklistPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Delete all events for a blacklisted user
async function deleteAllEventsForUser() {
if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
return;
}
try {
isLoading = true;
const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
showMessage(`Deleted ${result.deleted} events`, "success");
// Refresh the events list
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
await loadUserEvents();
} catch (error) {
console.error("Failed to delete events:", error);
showMessage("Failed to delete events: " + error.message, "error");
} finally {
isLoading = false;
}
}
// Get kind name
function getKindName(kind) {
const kindNames = {
0: "Metadata",
1: "Text Note",
3: "Follow List",
4: "Encrypted DM",
6: "Repost",
7: "Reaction",
14: "Chat Message",
16: "Order Message",
17: "Payment Receipt",
1063: "File Metadata",
10002: "Relay List",
30017: "Stall",
30018: "Product (NIP-15)",
30023: "Long-form",
30078: "App Data",
30402: "Product (NIP-99)",
30405: "Collection",
30406: "Shipping",
31555: "Review",
};
return kindNames[kind] || `Kind ${kind}`;
}
</script>
<div class="curation-view">
@@ -532,29 +724,97 @@
</div>
</div>
{:else}
<!-- Active Mode -->
<div class="tabs">
<button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
Trusted ({trustedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
Blacklist ({blacklistedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
Unclassified ({unclassifiedUsers.length})
</button>
<button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
Spam ({spamEvents.length})
</button>
<button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
Blocked IPs ({blockedIPs.length})
</button>
<button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
Settings
</button>
</div>
<!-- User Detail View -->
{#if selectedUser}
<div class="user-detail-view">
<div class="detail-header">
<div class="detail-header-left">
<button class="back-btn" on:click={closeUserDetail}>
&larr; Back
</button>
<h3>User Events</h3>
<span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
<span class="detail-count">{userEventsTotal} events</span>
</div>
<div class="detail-header-right">
{#if selectedUserType === "trusted"}
<button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{:else if selectedUserType === "blacklisted"}
<button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
Delete All Events
</button>
<button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
{:else}
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{/if}
</div>
</div>
<div class="tab-content">
<div class="events-list">
{#if loadingEvents && userEvents.length === 0}
<div class="loading">Loading events...</div>
{:else if userEvents.length === 0}
<div class="empty">No events found for this user.</div>
{:else}
{#each userEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">{getKindName(event.kind)}</span>
<span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
<span class="event-time">{formatDate(event.created_at * 1000)}</span>
</div>
<div class="event-content" class:expanded={expandedEvents[event.id]}>
{#if expandedEvents[event.id] || !isContentTruncated(event.content)}
<pre>{event.content || "(empty)"}</pre>
{:else}
<pre>{truncateContent(event.content)}...</pre>
{/if}
</div>
{#if isContentTruncated(event.content)}
<button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
{expandedEvents[event.id] ? "Show less" : "Show more"}
</button>
{/if}
</div>
{/each}
{#if userEvents.length < userEventsTotal}
<div class="load-more">
<button on:click={loadMoreEvents} disabled={loadingEvents}>
{loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
</button>
</div>
{/if}
{/if}
</div>
</div>
{:else}
<!-- Active Mode -->
<div class="tabs">
<button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
Trusted ({trustedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
Blacklist ({blacklistedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
Unclassified ({unclassifiedUsers.length})
</button>
<button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
Spam ({spamEvents.length})
</button>
<button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
Blocked IPs ({blockedIPs.length})
</button>
<button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
Settings
</button>
</div>
<div class="tab-content">
{#if activeTab === "trusted"}
<div class="section">
<h3>Trusted Publishers</h3>
@@ -579,7 +839,7 @@
<div class="list">
{#if trustedPubkeys.length > 0}
{#each trustedPubkeys as item}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.note}
@@ -587,7 +847,7 @@
{/if}
</div>
<div class="item-actions">
<button class="btn-danger" on:click={() => untrustPubkey(item.pubkey)}>
<button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
Remove
</button>
</div>
@@ -624,7 +884,7 @@
<div class="list">
{#if blacklistedPubkeys.length > 0}
{#each blacklistedPubkeys as item}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.reason}
@@ -632,7 +892,7 @@
{/if}
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => unblacklistPubkey(item.pubkey)}>
<button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
Remove
</button>
</div>
@@ -650,23 +910,28 @@
<h3>Unclassified Users</h3>
<p class="help-text">Users who have posted events but haven't been classified. Sorted by event count.</p>
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
Refresh
</button>
<div class="button-row">
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
Refresh
</button>
<button class="scan-btn" on:click={scanDatabase} disabled={isLoading}>
Scan Database
</button>
</div>
<div class="list">
{#if unclassifiedUsers.length > 0}
{#each unclassifiedUsers as user}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
<div class="item-main">
<span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
<span class="event-count">{user.total_events} events</span>
<span class="event-count">{user.event_count} events</span>
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => trustPubkey(user.pubkey, "")}>
<button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
Trust
</button>
<button class="btn-danger" on:click={() => blacklistPubkey(user.pubkey, "")}>
<button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
Blacklist
</button>
</div>
@@ -840,6 +1105,7 @@
</div>
{/if}
</div>
{/if}
{/if}
</div>
@@ -1149,6 +1415,26 @@
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.scan-btn {
padding: 0.5rem 1rem;
background: var(--warning, #f0ad4e);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.scan-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.list {
border: 1px solid var(--border-color);
border-radius: 4px;
@@ -1222,6 +1508,26 @@
font-size: 0.85em;
}
.btn-delete-all {
padding: 0.35rem 0.75rem;
background: #8B0000;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
}
.btn-delete-all:hover:not(:disabled) {
background: #660000;
}
.btn-delete-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
padding: 2rem;
text-align: center;
@@ -1229,4 +1535,187 @@
opacity: 0.6;
font-style: italic;
}
/* Clickable list items */
.list-item.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.list-item.clickable:hover {
background-color: var(--button-hover-bg);
}
/* User Detail View */
.user-detail-view {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.detail-header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.detail-header-left h3 {
margin: 0;
color: var(--text-color);
}
.detail-header-right {
display: flex;
gap: 0.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.back-btn:hover {
background: var(--button-hover-bg);
}
.detail-pubkey {
font-family: monospace;
font-size: 0.9em;
color: var(--text-color);
background: var(--bg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.detail-count {
font-size: 0.85em;
color: var(--success);
font-weight: 500;
}
/* Events List */
.events-list {
max-height: 600px;
overflow-y: auto;
}
.event-item {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.event-header {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.event-kind {
background: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.event-id {
font-family: monospace;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.event-time {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.event-content {
background: var(--card-bg);
border-radius: 4px;
padding: 0.75rem;
overflow: hidden;
}
.event-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.9em;
color: var(--text-color);
max-height: 150px;
overflow: hidden;
}
.event-content.expanded pre {
max-height: none;
}
.expand-btn {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.expand-btn:hover {
background: var(--accent-color);
color: var(--text-color);
}
.load-more {
text-align: center;
padding: 1rem;
}
.load-more button {
padding: 0.5rem 1.5rem;
background: var(--info);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
</style>

290
docs/NIP-CURATION.md Normal file
View File

@@ -0,0 +1,290 @@
# NIP-XX: Relay Curation Mode
`draft` `optional`
This NIP defines a relay operating mode where operators can curate content through a three-tier publisher classification system (trusted, blacklisted, unclassified) with rate limiting, IP-based flood protection, and event kind filtering. Configuration and management are performed through Nostr events and a NIP-86 JSON-RPC API.
## Motivation
Public relays face challenges managing spam, abuse, and resource consumption. Traditional approaches (pay-to-relay, invite-only, WoT-based) each have limitations. Curation mode provides relay operators with fine-grained control over who can publish what, while maintaining an open-by-default stance that allows unknown users to participate within limits.
## Overview
Curation mode introduces:
1. **Publisher Classification**: Three-tier system (trusted, blacklisted, unclassified)
2. **Rate Limiting**: Per-pubkey and per-IP daily event limits
3. **Kind Filtering**: Configurable allowed event kinds
4. **Configuration Event**: Kind 30078 replaceable event for relay configuration
5. **Management API**: NIP-86 JSON-RPC endpoints for administration
## Configuration Event (Kind 30078)
The relay MUST be configured with a kind 30078 replaceable event before accepting events from non-owner/admin pubkeys. This event uses the `d` tag value `curating-config`.
### Event Structure
```json
{
"kind": 30078,
"tags": [
["d", "curating-config"],
["daily_limit", "<number>"],
["ip_daily_limit", "<number>"],
["first_ban_hours", "<number>"],
["second_ban_hours", "<number>"],
["kind_category", "<category_id>"],
["kind", "<kind_number>"],
["kind_range", "<start>-<end>"]
],
"content": "{}",
"pubkey": "<owner_or_admin_pubkey>",
"created_at": <unix_timestamp>
}
```
### Configuration Tags
| Tag | Description | Default |
|-----|-------------|---------|
| `d` | MUST be `"curating-config"` | Required |
| `daily_limit` | Max events per day for unclassified users | 50 |
| `ip_daily_limit` | Max events per day from a single IP | 500 |
| `first_ban_hours` | First offense IP ban duration (hours) | 1 |
| `second_ban_hours` | Subsequent offense IP ban duration (hours) | 168 |
| `kind_category` | Predefined kind category (repeatable) | - |
| `kind` | Individual allowed kind number (repeatable) | - |
| `kind_range` | Allowed kind range as "start-end" (repeatable) | - |
### Kind Categories
Relays SHOULD support these predefined categories:
| Category ID | Kinds | Description |
|-------------|-------|-------------|
| `social` | 0, 1, 3, 6, 7, 10002 | Profiles, notes, contacts, reposts, reactions, relay lists |
| `dm` | 4, 14, 1059 | Direct messages (NIP-04, NIP-17, gift wraps) |
| `longform` | 30023, 30024 | Long-form articles and drafts |
| `media` | 1063, 20, 21, 22 | File metadata, picture, video, audio events |
| `lists` | 10000, 10001, 10003, 30000, 30001, 30003 | Mute lists, pins, bookmarks, people lists |
| `groups_nip29` | 9-12, 9000-9002, 39000-39002 | NIP-29 relay-based groups |
| `groups_nip72` | 34550, 1111, 4550 | NIP-72 moderated communities |
| `marketplace_nip15` | 30017-30020, 1021, 1022 | NIP-15 stalls and products |
| `marketplace_nip99` | 30402, 30403, 30405, 30406, 31555 | NIP-99 classified listings |
| `order_communication` | 16, 17 | Marketplace order messages |
Relays MAY define additional categories.
### Example Configuration Event
```json
{
"kind": 30078,
"tags": [
["d", "curating-config"],
["daily_limit", "100"],
["ip_daily_limit", "1000"],
["first_ban_hours", "2"],
["second_ban_hours", "336"],
["kind_category", "social"],
["kind_category", "dm"],
["kind", "1984"],
["kind_range", "30000-39999"]
],
"content": "{}",
"pubkey": "a1b2c3...",
"created_at": 1700000000
}
```
## Publisher Classification
### Trusted Publishers
- Unlimited publishing rights
- Bypass rate limiting and IP flood protection
- Events visible to all users
### Blacklisted Publishers
- Cannot publish any events
- Events rejected with `"blocked: pubkey is blacklisted"` notice
- Existing events hidden from queries (visible only to admins/owners)
### Unclassified Publishers (Default)
- Subject to daily event limit
- Subject to IP flood protection
- Events visible to all users
- Can be promoted to trusted or demoted to blacklisted
## Event Processing Flow
When an event is received, the relay MUST process it as follows:
1. **Configuration Check**: Reject if relay is not configured (no kind 30078 event)
2. **Access Level Check**: Determine pubkey's access level
- Owners and admins: always accept, bypass all limits
- IP-blocked: reject with temporary block notice
- Blacklisted: reject with blacklist notice
- Trusted: accept, bypass rate limits
- Unclassified: continue to rate limit checks
3. **Kind Filter**: Reject if event kind is not in allowed list
4. **Rate Limit Check**:
- Check pubkey's daily event count against `daily_limit`
- Check IP's daily event count against `ip_daily_limit`
5. **Accept or Reject**: Accept if all checks pass
### IP Flood Protection
When a pubkey exceeds `daily_limit`:
1. Record IP offense
2. If first offense: block IP for `first_ban_hours`
3. If subsequent offense: block IP for `second_ban_hours`
4. Track which pubkeys triggered the offense for admin review
## Management API (NIP-86)
All management endpoints require NIP-98 HTTP authentication from an owner or admin pubkey.
### Trust Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `trustpubkey` | `[pubkey_hex, note?]` | Add pubkey to trusted list |
| `untrustpubkey` | `[pubkey_hex]` | Remove pubkey from trusted list |
| `listtrustedpubkeys` | `[]` | List all trusted pubkeys |
### Blacklist Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `blacklistpubkey` | `[pubkey_hex, reason?]` | Add pubkey to blacklist |
| `unblacklistpubkey` | `[pubkey_hex]` | Remove pubkey from blacklist |
| `listblacklistedpubkeys` | `[]` | List all blacklisted pubkeys |
### User Inspection
| Method | Parameters | Description |
|--------|------------|-------------|
| `listunclassifiedusers` | `[limit?]` | List unclassified users sorted by event count |
| `geteventsforpubkey` | `[pubkey_hex, limit?, offset?]` | Get events from a pubkey |
| `deleteeventsforpubkey` | `[pubkey_hex]` | Delete all events from a blacklisted pubkey |
| `scanpubkeys` | `[]` | Scan database to populate unclassified users list |
### Spam Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `markspam` | `[event_id_hex, pubkey?, reason?]` | Flag event as spam (hides from queries) |
| `unmarkspam` | `[event_id_hex]` | Remove spam flag |
| `listspamevents` | `[]` | List spam-flagged events |
| `deleteevent` | `[event_id_hex]` | Permanently delete an event |
### IP Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `listblockedips` | `[]` | List currently blocked IPs |
| `unblockip` | `[ip_address]` | Remove IP block |
### Configuration
| Method | Parameters | Description |
|--------|------------|-------------|
| `getcuratingconfig` | `[]` | Get current configuration |
| `isconfigured` | `[]` | Check if relay is configured |
| `supportedmethods` | `[]` | List available management methods |
### Example API Request
```http
POST /api HTTP/1.1
Host: relay.example.com
Authorization: Nostr <base64_nip98_event>
Content-Type: application/json
```
### Example API Response
```json
{
"result": {
"success": true,
"message": "Pubkey added to trusted list"
}
}
```
## Event Visibility
| Viewer | Sees Trusted Events | Sees Blacklisted Events | Sees Spam-Flagged Events |
|--------|---------------------|-------------------------|--------------------------|
| Owner/Admin | Yes | Yes | Yes |
| Regular User | Yes | No | No |
## Relay Information Document
Relays implementing this NIP SHOULD advertise it in their NIP-11 relay information document:
```json
{
"supported_nips": [11, 86, "XX"],
"limitation": {
"curation_mode": true,
"daily_limit": 50,
"ip_daily_limit": 500
}
}
```
## Implementation Notes
### Rate Limit Reset
Daily counters SHOULD reset at UTC midnight (00:00:00 UTC).
### Caching
Implementations SHOULD cache trusted/blacklisted status and allowed kinds in memory for performance, refreshing periodically (e.g., hourly).
### Database Keys
Suggested key prefixes for persistent storage:
- `CURATING_ACL_CONFIG` - Current configuration
- `CURATING_ACL_TRUSTED_PUBKEY_{pubkey}` - Trusted publishers
- `CURATING_ACL_BLACKLISTED_PUBKEY_{pubkey}` - Blacklisted publishers
- `CURATING_ACL_EVENT_COUNT_{pubkey}_{date}` - Daily event counts
- `CURATING_ACL_IP_EVENT_COUNT_{ip}_{date}` - IP daily event counts
- `CURATING_ACL_IP_OFFENSE_{ip}` - IP offense tracking
- `CURATING_ACL_BLOCKED_IP_{ip}` - Active IP blocks
- `CURATING_ACL_SPAM_EVENT_{event_id}` - Spam-flagged events
## Security Considerations
1. **NIP-98 Authentication**: All management API calls MUST require valid NIP-98 authentication from owner or admin pubkeys
2. **IP Spoofing**: Relays SHOULD use `X-Forwarded-For` or `X-Real-IP` headers carefully, only trusting them from known reverse proxies
3. **Rate Limit Bypass**: Trusted status should be granted carefully as it bypasses all rate limiting
4. **Event Deletion**: Deleted events cannot be recovered; implementations SHOULD consider soft-delete with admin recovery option
## Compatibility
This NIP is compatible with:
- NIP-42 (Authentication): Can require auth before accepting events
- NIP-86 (Relay Management API): Uses NIP-86 for management endpoints
- NIP-98 (HTTP Auth): Uses NIP-98 for API authentication
## Reference Implementation
- ORLY Relay: https://github.com/mleku/orly
## Changelog
- Initial draft
## Changelog
- Initial draft

View File

@@ -4,12 +4,17 @@ package database
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/minio/sha256-simd"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// CuratingConfig represents the configuration for curating ACL mode
@@ -990,3 +995,236 @@ func kindInCategory(kind int, category string) bool {
}
return false
}
// ==================== Database Scanning ====================
// ScanResult contains the results of scanning all pubkeys in the database
type ScanResult struct {
TotalPubkeys int `json:"total_pubkeys"`
TotalEvents int `json:"total_events"`
Skipped int `json:"skipped"` // Trusted/blacklisted users skipped
}
// ScanAllPubkeys scans the database to find all unique pubkeys and count their events.
// This populates the event count data needed for the unclassified users list.
// It uses the SerialPubkey index to find all pubkeys, then counts events for each.
func (c *CuratingACL) ScanAllPubkeys() (*ScanResult, error) {
result := &ScanResult{}
// First, get all trusted and blacklisted pubkeys to skip
trusted, err := c.ListTrustedPubkeys()
if err != nil {
return nil, err
}
blacklisted, err := c.ListBlacklistedPubkeys()
if err != nil {
return nil, err
}
excludeSet := make(map[string]struct{})
for _, t := range trusted {
excludeSet[t.Pubkey] = struct{}{}
}
for _, b := range blacklisted {
excludeSet[b.Pubkey] = struct{}{}
}
// Scan the SerialPubkey index to get all pubkeys
pubkeys := make(map[string]struct{})
err = c.View(func(txn *badger.Txn) error {
// SerialPubkey prefix is "spk"
prefix := []byte("spk")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
// The value contains the 32-byte pubkey
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
if len(val) == 32 {
// Convert to hex
pubkeyHex := fmt.Sprintf("%x", val)
pubkeys[pubkeyHex] = struct{}{}
}
}
return nil
})
if err != nil {
return nil, err
}
result.TotalPubkeys = len(pubkeys)
// For each pubkey, count events and store the count
today := time.Now().Format("2006-01-02")
for pubkeyHex := range pubkeys {
// Skip if trusted or blacklisted
if _, excluded := excludeSet[pubkeyHex]; excluded {
result.Skipped++
continue
}
// Count events for this pubkey using the Pubkey index
count, err := c.countEventsForPubkey(pubkeyHex)
if err != nil {
continue
}
if count > 0 {
result.TotalEvents += count
// Store the event count
ec := PubkeyEventCount{
Pubkey: pubkeyHex,
Date: today,
Count: count,
LastEvent: time.Now(),
}
err = c.Update(func(txn *badger.Txn) error {
key := c.getEventCountKey(pubkeyHex, today)
data, err := json.Marshal(ec)
if err != nil {
return err
}
return txn.Set(key, data)
})
if err != nil {
continue
}
}
}
return result, nil
}
// EventSummary represents a simplified event for display in the UI
type EventSummary struct {
ID string `json:"id"`
Kind int `json:"kind"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
// GetEventsForPubkey fetches events for a pubkey, returning simplified event data
// limit specifies max events to return, offset is for pagination
func (c *CuratingACL) GetEventsForPubkey(pubkeyHex string, limit, offset int) ([]EventSummary, int, error) {
var events []EventSummary
// First, count total events for this pubkey
totalCount, err := c.countEventsForPubkey(pubkeyHex)
if err != nil {
return nil, 0, err
}
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return nil, 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to query events by author
// Use a larger limit to account for offset, then slice
queryLimit := uint(limit + offset)
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
Limit: &queryLimit,
}
// Query events using the database's QueryEvents method
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return nil, 0, err
}
// Apply offset and convert to EventSummary
for i, ev := range evs {
if i < offset {
continue
}
if len(events) >= limit {
break
}
events = append(events, EventSummary{
ID: hex.Enc(ev.ID),
Kind: int(ev.Kind),
Content: string(ev.Content),
CreatedAt: ev.CreatedAt,
})
}
return events, totalCount, nil
}
// DeleteEventsForPubkey deletes all events for a given pubkey
// Returns the number of events deleted
func (c *CuratingACL) DeleteEventsForPubkey(pubkeyHex string) (int, error) {
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to find all events by this author
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
}
// Query all events for this pubkey
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return 0, err
}
// Delete each event
deleted := 0
for _, ev := range evs {
if err := c.D.DeleteEvent(ctx, ev.ID); err != nil {
// Log error but continue deleting
continue
}
deleted++
}
return deleted, nil
}
// countEventsForPubkey counts events in the database for a given pubkey hex string
func (c *CuratingACL) countEventsForPubkey(pubkeyHex string) (int, error) {
count := 0
// Decode the pubkey hex to bytes
pubkeyBytes := make([]byte, 32)
for i := 0; i < 32 && i*2+1 < len(pubkeyHex); i++ {
fmt.Sscanf(pubkeyHex[i*2:i*2+2], "%02x", &pubkeyBytes[i])
}
// Compute the pubkey hash (SHA256 of pubkey, first 8 bytes)
// This matches the PubHash type in indexes/types/pubhash.go
pkh := sha256.Sum256(pubkeyBytes)
// Scan the Pubkey index (prefix "pc-") for this pubkey
err := c.View(func(txn *badger.Txn) error {
// Build prefix: "pc-" + 8-byte SHA256 hash of pubkey
prefix := make([]byte, 3+8)
copy(prefix[:3], []byte("pc-"))
copy(prefix[3:], pkh[:8])
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
count++
}
return nil
})
return count, err
}

View File

@@ -1 +1 @@
v0.49.2
v0.50.1