Files
next.orly.dev/pkg/database/curating-acl.go
woikos cb50a9c5c4
Some checks failed
Go / build-and-release (push) Failing after 4s
feat(curating): add user event viewer and delete functionality (v0.50.0)
- 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

1231 lines
31 KiB
Go

//go:build !(js && wasm)
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
// This is parsed from a kind 30078 event with d-tag "curating-config"
type CuratingConfig struct {
DailyLimit int `json:"daily_limit"` // Max events per day for unclassified users
IPDailyLimit int `json:"ip_daily_limit"` // Max events per day from a single IP (flood protection)
FirstBanHours int `json:"first_ban_hours"` // IP ban duration for first offense
SecondBanHours int `json:"second_ban_hours"` // IP ban duration for second+ offense
AllowedKinds []int `json:"allowed_kinds"` // Explicit kind numbers
AllowedRanges []string `json:"allowed_ranges"` // Kind ranges like "1000-1999"
KindCategories []string `json:"kind_categories"` // Category IDs like "social", "dm"
ConfigEventID string `json:"config_event_id"` // ID of the config event
ConfigPubkey string `json:"config_pubkey"` // Pubkey that published config
ConfiguredAt int64 `json:"configured_at"` // Timestamp of config event
}
// TrustedPubkey represents an explicitly trusted publisher
type TrustedPubkey struct {
Pubkey string `json:"pubkey"`
Note string `json:"note,omitempty"`
Added time.Time `json:"added"`
}
// BlacklistedPubkey represents a blacklisted publisher
type BlacklistedPubkey struct {
Pubkey string `json:"pubkey"`
Reason string `json:"reason,omitempty"`
Added time.Time `json:"added"`
}
// PubkeyEventCount tracks daily event counts for rate limiting
type PubkeyEventCount struct {
Pubkey string `json:"pubkey"`
Date string `json:"date"` // YYYY-MM-DD format
Count int `json:"count"`
LastEvent time.Time `json:"last_event"`
}
// IPOffense tracks rate limit violations from IPs
type IPOffense struct {
IP string `json:"ip"`
OffenseCount int `json:"offense_count"`
PubkeysHit []string `json:"pubkeys_hit"` // Pubkeys that hit rate limit from this IP
LastOffense time.Time `json:"last_offense"`
}
// CuratingBlockedIP represents a temporarily blocked IP with expiration
type CuratingBlockedIP struct {
IP string `json:"ip"`
Reason string `json:"reason"`
ExpiresAt time.Time `json:"expires_at"`
Added time.Time `json:"added"`
}
// SpamEvent represents an event flagged as spam
type SpamEvent struct {
EventID string `json:"event_id"`
Pubkey string `json:"pubkey"`
Reason string `json:"reason,omitempty"`
Added time.Time `json:"added"`
}
// UnclassifiedUser represents a user who hasn't been trusted or blacklisted
type UnclassifiedUser struct {
Pubkey string `json:"pubkey"`
EventCount int `json:"event_count"`
LastEvent time.Time `json:"last_event"`
}
// CuratingACL database operations
type CuratingACL struct {
*D
}
// NewCuratingACL creates a new CuratingACL instance
func NewCuratingACL(db *D) *CuratingACL {
return &CuratingACL{D: db}
}
// ==================== Configuration ====================
// SaveConfig saves the curating configuration
func (c *CuratingACL) SaveConfig(config CuratingConfig) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getConfigKey()
data, err := json.Marshal(config)
if err != nil {
return err
}
return txn.Set(key, data)
})
}
// GetConfig returns the curating configuration
func (c *CuratingACL) GetConfig() (CuratingConfig, error) {
var config CuratingConfig
err := c.View(func(txn *badger.Txn) error {
key := c.getConfigKey()
item, err := txn.Get(key)
if err != nil {
if err == badger.ErrKeyNotFound {
return nil // Return empty config
}
return err
}
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
return json.Unmarshal(val, &config)
})
return config, err
}
// IsConfigured returns true if a configuration event has been set
func (c *CuratingACL) IsConfigured() (bool, error) {
config, err := c.GetConfig()
if err != nil {
return false, err
}
return config.ConfigEventID != "", nil
}
// ==================== Trusted Pubkeys ====================
// SaveTrustedPubkey saves a trusted pubkey to the database
func (c *CuratingACL) SaveTrustedPubkey(pubkey string, note string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getTrustedPubkeyKey(pubkey)
trusted := TrustedPubkey{
Pubkey: pubkey,
Note: note,
Added: time.Now(),
}
data, err := json.Marshal(trusted)
if err != nil {
return err
}
return txn.Set(key, data)
})
}
// RemoveTrustedPubkey removes a trusted pubkey from the database
func (c *CuratingACL) RemoveTrustedPubkey(pubkey string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getTrustedPubkeyKey(pubkey)
return txn.Delete(key)
})
}
// ListTrustedPubkeys returns all trusted pubkeys
func (c *CuratingACL) ListTrustedPubkeys() ([]TrustedPubkey, error) {
var trusted []TrustedPubkey
err := c.View(func(txn *badger.Txn) error {
prefix := c.getTrustedPubkeyPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var t TrustedPubkey
if err := json.Unmarshal(val, &t); err != nil {
continue
}
trusted = append(trusted, t)
}
return nil
})
return trusted, err
}
// IsPubkeyTrusted checks if a pubkey is trusted
func (c *CuratingACL) IsPubkeyTrusted(pubkey string) (bool, error) {
var trusted bool
err := c.View(func(txn *badger.Txn) error {
key := c.getTrustedPubkeyKey(pubkey)
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
trusted = false
return nil
}
if err != nil {
return err
}
trusted = true
return nil
})
return trusted, err
}
// ==================== Blacklisted Pubkeys ====================
// SaveBlacklistedPubkey saves a blacklisted pubkey to the database
func (c *CuratingACL) SaveBlacklistedPubkey(pubkey string, reason string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getBlacklistedPubkeyKey(pubkey)
blacklisted := BlacklistedPubkey{
Pubkey: pubkey,
Reason: reason,
Added: time.Now(),
}
data, err := json.Marshal(blacklisted)
if err != nil {
return err
}
return txn.Set(key, data)
})
}
// RemoveBlacklistedPubkey removes a blacklisted pubkey from the database
func (c *CuratingACL) RemoveBlacklistedPubkey(pubkey string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getBlacklistedPubkeyKey(pubkey)
return txn.Delete(key)
})
}
// ListBlacklistedPubkeys returns all blacklisted pubkeys
func (c *CuratingACL) ListBlacklistedPubkeys() ([]BlacklistedPubkey, error) {
var blacklisted []BlacklistedPubkey
err := c.View(func(txn *badger.Txn) error {
prefix := c.getBlacklistedPubkeyPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var b BlacklistedPubkey
if err := json.Unmarshal(val, &b); err != nil {
continue
}
blacklisted = append(blacklisted, b)
}
return nil
})
return blacklisted, err
}
// IsPubkeyBlacklisted checks if a pubkey is blacklisted
func (c *CuratingACL) IsPubkeyBlacklisted(pubkey string) (bool, error) {
var blacklisted bool
err := c.View(func(txn *badger.Txn) error {
key := c.getBlacklistedPubkeyKey(pubkey)
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
blacklisted = false
return nil
}
if err != nil {
return err
}
blacklisted = true
return nil
})
return blacklisted, err
}
// ==================== Event Counting ====================
// GetEventCount returns the event count for a pubkey on a specific date
func (c *CuratingACL) GetEventCount(pubkey, date string) (int, error) {
var count int
err := c.View(func(txn *badger.Txn) error {
key := c.getEventCountKey(pubkey, date)
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
count = 0
return nil
}
if err != nil {
return err
}
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
var ec PubkeyEventCount
if err := json.Unmarshal(val, &ec); err != nil {
return err
}
count = ec.Count
return nil
})
return count, err
}
// IncrementEventCount increments and returns the new event count for a pubkey
func (c *CuratingACL) IncrementEventCount(pubkey, date string) (int, error) {
var newCount int
err := c.Update(func(txn *badger.Txn) error {
key := c.getEventCountKey(pubkey, date)
var ec PubkeyEventCount
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
ec = PubkeyEventCount{
Pubkey: pubkey,
Date: date,
Count: 0,
LastEvent: time.Now(),
}
} else if err != nil {
return err
} else {
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
if err := json.Unmarshal(val, &ec); err != nil {
return err
}
}
ec.Count++
ec.LastEvent = time.Now()
newCount = ec.Count
data, err := json.Marshal(ec)
if err != nil {
return err
}
return txn.Set(key, data)
})
return newCount, err
}
// CleanupOldEventCounts removes event counts older than the specified date
func (c *CuratingACL) CleanupOldEventCounts(beforeDate string) error {
return c.Update(func(txn *badger.Txn) error {
prefix := c.getEventCountPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
var keysToDelete [][]byte
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var ec PubkeyEventCount
if err := json.Unmarshal(val, &ec); err != nil {
continue
}
if ec.Date < beforeDate {
keysToDelete = append(keysToDelete, item.KeyCopy(nil))
}
}
for _, key := range keysToDelete {
if err := txn.Delete(key); err != nil {
return err
}
}
return nil
})
}
// ==================== IP Event Counting ====================
// IPEventCount tracks events from an IP address per day (flood protection)
type IPEventCount struct {
IP string `json:"ip"`
Date string `json:"date"`
Count int `json:"count"`
LastEvent time.Time `json:"last_event"`
}
// GetIPEventCount returns the total event count for an IP on a specific date
func (c *CuratingACL) GetIPEventCount(ip, date string) (int, error) {
var count int
err := c.View(func(txn *badger.Txn) error {
key := c.getIPEventCountKey(ip, date)
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
count = 0
return nil
}
if err != nil {
return err
}
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
var ec IPEventCount
if err := json.Unmarshal(val, &ec); err != nil {
return err
}
count = ec.Count
return nil
})
return count, err
}
// IncrementIPEventCount increments and returns the new event count for an IP
func (c *CuratingACL) IncrementIPEventCount(ip, date string) (int, error) {
var newCount int
err := c.Update(func(txn *badger.Txn) error {
key := c.getIPEventCountKey(ip, date)
var ec IPEventCount
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
ec = IPEventCount{
IP: ip,
Date: date,
Count: 0,
LastEvent: time.Now(),
}
} else if err != nil {
return err
} else {
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
if err := json.Unmarshal(val, &ec); err != nil {
return err
}
}
ec.Count++
ec.LastEvent = time.Now()
newCount = ec.Count
data, err := json.Marshal(ec)
if err != nil {
return err
}
return txn.Set(key, data)
})
return newCount, err
}
// CleanupOldIPEventCounts removes IP event counts older than the specified date
func (c *CuratingACL) CleanupOldIPEventCounts(beforeDate string) error {
return c.Update(func(txn *badger.Txn) error {
prefix := c.getIPEventCountPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
var keysToDelete [][]byte
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var ec IPEventCount
if err := json.Unmarshal(val, &ec); err != nil {
continue
}
if ec.Date < beforeDate {
keysToDelete = append(keysToDelete, item.KeyCopy(nil))
}
}
for _, key := range keysToDelete {
if err := txn.Delete(key); err != nil {
return err
}
}
return nil
})
}
func (c *CuratingACL) getIPEventCountKey(ip, date string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_IP_EVENT_COUNT_")
buf.WriteString(ip)
buf.WriteString("_")
buf.WriteString(date)
return buf.Bytes()
}
func (c *CuratingACL) getIPEventCountPrefix() []byte {
return []byte("CURATING_ACL_IP_EVENT_COUNT_")
}
// ==================== IP Offense Tracking ====================
// GetIPOffense returns the offense record for an IP
func (c *CuratingACL) GetIPOffense(ip string) (*IPOffense, error) {
var offense *IPOffense
err := c.View(func(txn *badger.Txn) error {
key := c.getIPOffenseKey(ip)
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
offense = new(IPOffense)
return json.Unmarshal(val, offense)
})
return offense, err
}
// RecordIPOffense records a rate limit violation from an IP for a pubkey
// Returns the new offense count
func (c *CuratingACL) RecordIPOffense(ip, pubkey string) (int, error) {
var newCount int
err := c.Update(func(txn *badger.Txn) error {
key := c.getIPOffenseKey(ip)
var offense IPOffense
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
offense = IPOffense{
IP: ip,
OffenseCount: 0,
PubkeysHit: []string{},
LastOffense: time.Now(),
}
} else if err != nil {
return err
} else {
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
if err := json.Unmarshal(val, &offense); err != nil {
return err
}
}
// Add pubkey if not already in list
found := false
for _, p := range offense.PubkeysHit {
if p == pubkey {
found = true
break
}
}
if !found {
offense.PubkeysHit = append(offense.PubkeysHit, pubkey)
offense.OffenseCount++
}
offense.LastOffense = time.Now()
newCount = offense.OffenseCount
data, err := json.Marshal(offense)
if err != nil {
return err
}
return txn.Set(key, data)
})
return newCount, err
}
// ==================== IP Blocking ====================
// BlockIP blocks an IP for a specified duration
func (c *CuratingACL) BlockIP(ip string, duration time.Duration, reason string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getBlockedIPKey(ip)
blocked := CuratingBlockedIP{
IP: ip,
Reason: reason,
ExpiresAt: time.Now().Add(duration),
Added: time.Now(),
}
data, err := json.Marshal(blocked)
if err != nil {
return err
}
return txn.Set(key, data)
})
}
// UnblockIP removes an IP from the blocked list
func (c *CuratingACL) UnblockIP(ip string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getBlockedIPKey(ip)
return txn.Delete(key)
})
}
// IsIPBlocked checks if an IP is blocked and returns expiration time
func (c *CuratingACL) IsIPBlocked(ip string) (bool, time.Time, error) {
var blocked bool
var expiresAt time.Time
err := c.View(func(txn *badger.Txn) error {
key := c.getBlockedIPKey(ip)
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
blocked = false
return nil
}
if err != nil {
return err
}
val, err := item.ValueCopy(nil)
if err != nil {
return err
}
var b CuratingBlockedIP
if err := json.Unmarshal(val, &b); err != nil {
return err
}
if time.Now().After(b.ExpiresAt) {
// Block has expired
blocked = false
return nil
}
blocked = true
expiresAt = b.ExpiresAt
return nil
})
return blocked, expiresAt, err
}
// ListBlockedIPs returns all blocked IPs (including expired ones)
func (c *CuratingACL) ListBlockedIPs() ([]CuratingBlockedIP, error) {
var blocked []CuratingBlockedIP
err := c.View(func(txn *badger.Txn) error {
prefix := c.getBlockedIPPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var b CuratingBlockedIP
if err := json.Unmarshal(val, &b); err != nil {
continue
}
blocked = append(blocked, b)
}
return nil
})
return blocked, err
}
// CleanupExpiredIPBlocks removes expired IP blocks
func (c *CuratingACL) CleanupExpiredIPBlocks() error {
return c.Update(func(txn *badger.Txn) error {
prefix := c.getBlockedIPPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
now := time.Now()
var keysToDelete [][]byte
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var b CuratingBlockedIP
if err := json.Unmarshal(val, &b); err != nil {
continue
}
if now.After(b.ExpiresAt) {
keysToDelete = append(keysToDelete, item.KeyCopy(nil))
}
}
for _, key := range keysToDelete {
if err := txn.Delete(key); err != nil {
return err
}
}
return nil
})
}
// ==================== Spam Events ====================
// MarkEventAsSpam marks an event as spam
func (c *CuratingACL) MarkEventAsSpam(eventID, pubkey, reason string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getSpamEventKey(eventID)
spam := SpamEvent{
EventID: eventID,
Pubkey: pubkey,
Reason: reason,
Added: time.Now(),
}
data, err := json.Marshal(spam)
if err != nil {
return err
}
return txn.Set(key, data)
})
}
// UnmarkEventAsSpam removes the spam flag from an event
func (c *CuratingACL) UnmarkEventAsSpam(eventID string) error {
return c.Update(func(txn *badger.Txn) error {
key := c.getSpamEventKey(eventID)
return txn.Delete(key)
})
}
// IsEventSpam checks if an event is marked as spam
func (c *CuratingACL) IsEventSpam(eventID string) (bool, error) {
var spam bool
err := c.View(func(txn *badger.Txn) error {
key := c.getSpamEventKey(eventID)
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
spam = false
return nil
}
if err != nil {
return err
}
spam = true
return nil
})
return spam, err
}
// ListSpamEvents returns all spam events
func (c *CuratingACL) ListSpamEvents() ([]SpamEvent, error) {
var spam []SpamEvent
err := c.View(func(txn *badger.Txn) error {
prefix := c.getSpamEventPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var s SpamEvent
if err := json.Unmarshal(val, &s); err != nil {
continue
}
spam = append(spam, s)
}
return nil
})
return spam, err
}
// ==================== Unclassified Users ====================
// ListUnclassifiedUsers returns users who are neither trusted nor blacklisted
// sorted by event count descending
func (c *CuratingACL) ListUnclassifiedUsers(limit int) ([]UnclassifiedUser, error) {
// First, get all trusted and blacklisted pubkeys to exclude
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{}{}
}
// Now iterate through event counts and aggregate by pubkey
pubkeyCounts := make(map[string]*UnclassifiedUser)
err = c.View(func(txn *badger.Txn) error {
prefix := c.getEventCountPrefix()
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
var ec PubkeyEventCount
if err := json.Unmarshal(val, &ec); err != nil {
continue
}
// Skip if trusted or blacklisted
if _, excluded := excludeSet[ec.Pubkey]; excluded {
continue
}
if existing, ok := pubkeyCounts[ec.Pubkey]; ok {
existing.EventCount += ec.Count
if ec.LastEvent.After(existing.LastEvent) {
existing.LastEvent = ec.LastEvent
}
} else {
pubkeyCounts[ec.Pubkey] = &UnclassifiedUser{
Pubkey: ec.Pubkey,
EventCount: ec.Count,
LastEvent: ec.LastEvent,
}
}
}
return nil
})
if err != nil {
return nil, err
}
// Convert to slice and sort by event count descending
var users []UnclassifiedUser
for _, u := range pubkeyCounts {
users = append(users, *u)
}
sort.Slice(users, func(i, j int) bool {
return users[i].EventCount > users[j].EventCount
})
// Apply limit
if limit > 0 && len(users) > limit {
users = users[:limit]
}
return users, nil
}
// ==================== Key Generation ====================
func (c *CuratingACL) getConfigKey() []byte {
return []byte("CURATING_ACL_CONFIG")
}
func (c *CuratingACL) getTrustedPubkeyKey(pubkey string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_TRUSTED_PUBKEY_")
buf.WriteString(pubkey)
return buf.Bytes()
}
func (c *CuratingACL) getTrustedPubkeyPrefix() []byte {
return []byte("CURATING_ACL_TRUSTED_PUBKEY_")
}
func (c *CuratingACL) getBlacklistedPubkeyKey(pubkey string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_BLACKLISTED_PUBKEY_")
buf.WriteString(pubkey)
return buf.Bytes()
}
func (c *CuratingACL) getBlacklistedPubkeyPrefix() []byte {
return []byte("CURATING_ACL_BLACKLISTED_PUBKEY_")
}
func (c *CuratingACL) getEventCountKey(pubkey, date string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_EVENT_COUNT_")
buf.WriteString(pubkey)
buf.WriteString("_")
buf.WriteString(date)
return buf.Bytes()
}
func (c *CuratingACL) getEventCountPrefix() []byte {
return []byte("CURATING_ACL_EVENT_COUNT_")
}
func (c *CuratingACL) getIPOffenseKey(ip string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_IP_OFFENSE_")
buf.WriteString(ip)
return buf.Bytes()
}
func (c *CuratingACL) getBlockedIPKey(ip string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_BLOCKED_IP_")
buf.WriteString(ip)
return buf.Bytes()
}
func (c *CuratingACL) getBlockedIPPrefix() []byte {
return []byte("CURATING_ACL_BLOCKED_IP_")
}
func (c *CuratingACL) getSpamEventKey(eventID string) []byte {
buf := new(bytes.Buffer)
buf.WriteString("CURATING_ACL_SPAM_EVENT_")
buf.WriteString(eventID)
return buf.Bytes()
}
func (c *CuratingACL) getSpamEventPrefix() []byte {
return []byte("CURATING_ACL_SPAM_EVENT_")
}
// ==================== Kind Checking Helpers ====================
// IsKindAllowed checks if an event kind is allowed based on config
func (c *CuratingACL) IsKindAllowed(kind int, config *CuratingConfig) bool {
if config == nil {
return false
}
// Check explicit kinds
for _, k := range config.AllowedKinds {
if k == kind {
return true
}
}
// Check ranges
for _, rangeStr := range config.AllowedRanges {
if kindInRange(kind, rangeStr) {
return true
}
}
// Check categories
for _, cat := range config.KindCategories {
if kindInCategory(kind, cat) {
return true
}
}
return false
}
// kindInRange checks if a kind is within a range string like "1000-1999"
func kindInRange(kind int, rangeStr string) bool {
var start, end int
n, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end)
if err != nil || n != 2 {
return false
}
return kind >= start && kind <= end
}
// kindInCategory checks if a kind belongs to a predefined category
func kindInCategory(kind int, category string) bool {
categories := map[string][]int{
"social": {0, 1, 3, 6, 7, 10002},
"dm": {4, 14, 1059},
"longform": {30023, 30024},
"media": {1063, 20, 21, 22},
"marketplace": {30017, 30018, 30019, 30020, 1021, 1022}, // Legacy alias
"marketplace_nip15": {30017, 30018, 30019, 30020, 1021, 1022},
"marketplace_nip99": {30402, 30403, 30405, 30406, 31555}, // NIP-99/Gamma Markets (Plebeian Market)
"order_communication": {16, 17}, // Gamma Markets order messages
"groups_nip29": {9, 10, 11, 12, 9000, 9001, 9002, 39000, 39001, 39002},
"groups_nip72": {34550, 1111, 4550},
"lists": {10000, 10001, 10003, 30000, 30001, 30003},
}
kinds, ok := categories[category]
if !ok {
return false
}
for _, k := range kinds {
if k == kind {
return true
}
}
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
}