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>
This commit is contained in:
woikos
2026-01-14 19:19:33 +01:00
parent c5be98bcaa
commit cb50a9c5c4
7 changed files with 704 additions and 46 deletions

View File

@@ -145,6 +145,10 @@ func (s *Server) handleCuratingNIP86Method(request NIP86Request, curatingACL *ac
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}
}
@@ -170,6 +174,8 @@ func (s *Server) handleCuratingSupportedMethods() NIP86Response {
"unblockip",
"isconfigured",
"scanpubkeys",
"geteventsforpubkey",
"deleteeventsforpubkey",
}
return NIP86Response{Result: methods}
}
@@ -624,3 +630,107 @@ func (s *Server) handleScanPubkeys(dbACL *database.CuratingACL) NIP86Response {
"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,
@@ -443,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">
@@ -545,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>
@@ -592,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}
@@ -600,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>
@@ -637,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}
@@ -645,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>
@@ -675,16 +922,16 @@
<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.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>
@@ -858,6 +1105,7 @@
</div>
{/if}
</div>
{/if}
{/if}
</div>
@@ -1260,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;
@@ -1267,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>

View File

@@ -4,6 +4,7 @@ package database
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
@@ -11,6 +12,9 @@ import (
"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
@@ -1099,6 +1103,99 @@ func (c *CuratingACL) ScanAllPubkeys() (*ScanResult, error) {
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

View File

@@ -1 +1 @@
v0.49.2
v0.50.0