Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e38edd2c | ||
|
|
cb50a9c5c4 | ||
|
|
c5be98bcaa | ||
|
|
417866ebf4 | ||
|
|
0e87337723 | ||
|
|
b10851c209 |
@@ -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,
|
||||
}}
|
||||
}
|
||||
|
||||
2
app/web/dist/bundle.css
vendored
2
app/web/dist/bundle.css
vendored
File diff suppressed because one or more lines are too long
28
app/web/dist/bundle.js
vendored
28
app/web/dist/bundle.js
vendored
File diff suppressed because one or more lines are too long
2
app/web/dist/bundle.js.map
vendored
2
app/web/dist/bundle.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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}>
|
||||
← 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
290
docs/NIP-CURATION.md
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.49.2
|
||||
v0.50.1
|
||||
|
||||
Reference in New Issue
Block a user