Files
next.orly.dev/pkg/database/curating-acl.go
woikos 0e87337723 feat(curating): add scanpubkeys API method to retroactively populate unclassified users
- Add ScanAllPubkeys method to scan SerialPubkey index for all pubkeys
- Count events for each pubkey using the Pubkey index
- Store event counts in CURATING_ACL_EVENT_COUNT_ prefix
- Add NIP-86 "scanpubkeys" API endpoint to trigger the scan

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:26:31 +01:00

1130 lines
28 KiB
Go

//go:build !(js && wasm)
package database
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/dgraph-io/badger/v4"
)
// 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
}
// 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])
}
// Scan the Pubkey index (prefix "pc-") for this pubkey
err := c.View(func(txn *badger.Txn) error {
// Build prefix: "pc-" + 8-byte pubkey hash
// The pubkey hash is the first 8 bytes of the pubkey
prefix := make([]byte, 3+8)
copy(prefix[:3], []byte("pc-"))
copy(prefix[3:], pubkeyBytes[:8])
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
count++
}
return nil
})
return count, err
}