curating.go raw

   1  package acl
   2  
   3  import (
   4  	"context"
   5  	"encoding/hex"
   6  	"reflect"
   7  	"strconv"
   8  	"strings"
   9  	"sync"
  10  	"time"
  11  
  12  	"next.orly.dev/pkg/lol/chk"
  13  	"next.orly.dev/pkg/lol/errorf"
  14  	"next.orly.dev/pkg/lol/log"
  15  	"next.orly.dev/app/config"
  16  	"next.orly.dev/pkg/database"
  17  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  18  	"next.orly.dev/pkg/nostr/encoders/event"
  19  	"next.orly.dev/pkg/utils"
  20  )
  21  
  22  // Default values for curating mode
  23  const (
  24  	DefaultDailyLimit      = 50
  25  	DefaultIPDailyLimit    = 500 // Max events per IP per day (flood protection)
  26  	DefaultFirstBanHours   = 1
  27  	DefaultSecondBanHours  = 168 // 1 week
  28  	CuratingConfigKind     = 30078
  29  	CuratingConfigDTag     = "curating-config"
  30  )
  31  
  32  // Curating implements the curating ACL mode with three-tier publisher classification:
  33  // - Trusted: Unlimited publishing
  34  // - Blacklisted: Cannot publish
  35  // - Unclassified: Rate-limited publishing (default 50/day)
  36  type Curating struct {
  37  	// Ctx holds the context for the ACL.
  38  	// Deprecated: Use Context() method instead of accessing directly.
  39  	Ctx         context.Context
  40  	cfg         *config.C
  41  	db          database.Database
  42  	curatingACL *database.CuratingACL
  43  	owners      [][]byte
  44  	admins      [][]byte
  45  	mx          sync.RWMutex
  46  
  47  	// In-memory caches for performance
  48  	trustedCache     map[string]bool
  49  	blacklistedCache map[string]bool
  50  	kindCache        map[int]bool
  51  	configCache      *database.CuratingConfig
  52  	cacheMx          sync.RWMutex
  53  }
  54  
  55  // Context returns the ACL context.
  56  func (c *Curating) Context() context.Context {
  57  	return c.Ctx
  58  }
  59  
  60  func (c *Curating) Configure(cfg ...any) (err error) {
  61  	log.I.F("configuring curating ACL")
  62  	for _, ca := range cfg {
  63  		switch cv := ca.(type) {
  64  		case *config.C:
  65  			c.cfg = cv
  66  		case database.Database:
  67  			c.db = cv
  68  			// CuratingACL requires the concrete Badger database type
  69  			if d, ok := cv.(*database.D); ok {
  70  				c.curatingACL = database.NewCuratingACL(d)
  71  			} else {
  72  				log.W.F("curating ACL: database is not Badger, curating ACL features will be limited")
  73  			}
  74  		case context.Context:
  75  			c.Ctx = cv
  76  		default:
  77  			err = errorf.E("invalid type: %T", reflect.TypeOf(ca))
  78  		}
  79  	}
  80  	if c.cfg == nil || c.db == nil {
  81  		err = errorf.E("both config and database must be set")
  82  		return
  83  	}
  84  
  85  	// Initialize caches
  86  	c.trustedCache = make(map[string]bool)
  87  	c.blacklistedCache = make(map[string]bool)
  88  	c.kindCache = make(map[int]bool)
  89  
  90  	// Load owners from config
  91  	for _, owner := range c.cfg.Owners {
  92  		var own []byte
  93  		if o, e := bech32encoding.NpubOrHexToPublicKeyBinary(owner); chk.E(e) {
  94  			continue
  95  		} else {
  96  			own = o
  97  		}
  98  		c.owners = append(c.owners, own)
  99  	}
 100  
 101  	// Load admins from config
 102  	for _, admin := range c.cfg.Admins {
 103  		var adm []byte
 104  		if a, e := bech32encoding.NpubOrHexToPublicKeyBinary(admin); chk.E(e) {
 105  			continue
 106  		} else {
 107  			adm = a
 108  		}
 109  		c.admins = append(c.admins, adm)
 110  	}
 111  
 112  	// Refresh caches from database
 113  	if err = c.RefreshCaches(); err != nil {
 114  		log.W.F("curating ACL: failed to refresh caches: %v", err)
 115  	}
 116  
 117  	return nil
 118  }
 119  
 120  func (c *Curating) GetAccessLevel(pub []byte, address string) (level string) {
 121  	c.mx.RLock()
 122  	defer c.mx.RUnlock()
 123  
 124  	pubkeyHex := hex.EncodeToString(pub)
 125  
 126  	// Check owners first
 127  	for _, v := range c.owners {
 128  		if utils.FastEqual(v, pub) {
 129  			return "owner"
 130  		}
 131  	}
 132  
 133  	// Check admins
 134  	for _, v := range c.admins {
 135  		if utils.FastEqual(v, pub) {
 136  			return "admin"
 137  		}
 138  	}
 139  
 140  	// curatingACL may be nil when database is not Badger (e.g., gRPC proxy).
 141  	// Skip database checks and fall through to default write access.
 142  	if c.curatingACL == nil {
 143  		return "write"
 144  	}
 145  
 146  	// Check if IP is blocked
 147  	if address != "" {
 148  		blocked, _, err := c.curatingACL.IsIPBlocked(address)
 149  		if err == nil && blocked {
 150  			return "blocked"
 151  		}
 152  	}
 153  
 154  	// Check if pubkey is blacklisted (check cache first)
 155  	c.cacheMx.RLock()
 156  	if c.blacklistedCache[pubkeyHex] {
 157  		c.cacheMx.RUnlock()
 158  		return "banned"
 159  	}
 160  	c.cacheMx.RUnlock()
 161  
 162  	// Double-check database for blacklisted
 163  	blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex)
 164  	if blacklisted {
 165  		// Update cache
 166  		c.cacheMx.Lock()
 167  		c.blacklistedCache[pubkeyHex] = true
 168  		c.cacheMx.Unlock()
 169  		return "banned"
 170  	}
 171  
 172  	// All other users get write access (rate limiting handled in CheckPolicy)
 173  	return "write"
 174  }
 175  
 176  // CheckPolicy implements the PolicyChecker interface for event-level filtering
 177  func (c *Curating) CheckPolicy(ev *event.E) (allowed bool, err error) {
 178  	// If curatingACL is nil (non-Badger DB), allow everything
 179  	if c.curatingACL == nil {
 180  		return true, nil
 181  	}
 182  
 183  	pubkeyHex := hex.EncodeToString(ev.Pubkey)
 184  
 185  	// Check if configured
 186  	config, err := c.GetConfig()
 187  	if err != nil {
 188  		return false, errorf.E("failed to get config: %v", err)
 189  	}
 190  	if config.ConfigEventID == "" {
 191  		return false, errorf.E("curating mode not configured: please publish a configuration event")
 192  	}
 193  
 194  	// Check if event is spam-flagged
 195  	isSpam, _ := c.curatingACL.IsEventSpam(hex.EncodeToString(ev.ID[:]))
 196  	if isSpam {
 197  		return false, errorf.E("blocked: event is flagged as spam")
 198  	}
 199  
 200  	// Check if event kind is allowed
 201  	if !c.curatingACL.IsKindAllowed(int(ev.Kind), &config) {
 202  		return false, errorf.E("blocked: event kind %d is not in the allow list", ev.Kind)
 203  	}
 204  
 205  	// Check if pubkey is blacklisted
 206  	c.cacheMx.RLock()
 207  	isBlacklisted := c.blacklistedCache[pubkeyHex]
 208  	c.cacheMx.RUnlock()
 209  	if !isBlacklisted {
 210  		isBlacklisted, _ = c.curatingACL.IsPubkeyBlacklisted(pubkeyHex)
 211  	}
 212  	if isBlacklisted {
 213  		return false, errorf.E("blocked: pubkey is blacklisted")
 214  	}
 215  
 216  	// Check if pubkey is trusted (bypass rate limiting)
 217  	c.cacheMx.RLock()
 218  	isTrusted := c.trustedCache[pubkeyHex]
 219  	c.cacheMx.RUnlock()
 220  	if !isTrusted {
 221  		isTrusted, _ = c.curatingACL.IsPubkeyTrusted(pubkeyHex)
 222  		if isTrusted {
 223  			// Update cache
 224  			c.cacheMx.Lock()
 225  			c.trustedCache[pubkeyHex] = true
 226  			c.cacheMx.Unlock()
 227  		}
 228  	}
 229  	if isTrusted {
 230  		return true, nil
 231  	}
 232  
 233  	// Check if owner or admin (bypass rate limiting)
 234  	for _, v := range c.owners {
 235  		if utils.FastEqual(v, ev.Pubkey) {
 236  			return true, nil
 237  		}
 238  	}
 239  	for _, v := range c.admins {
 240  		if utils.FastEqual(v, ev.Pubkey) {
 241  			return true, nil
 242  		}
 243  	}
 244  
 245  	// For unclassified users, check rate limit
 246  	today := time.Now().Format("2006-01-02")
 247  	dailyLimit := config.DailyLimit
 248  	if dailyLimit == 0 {
 249  		dailyLimit = DefaultDailyLimit
 250  	}
 251  
 252  	count, err := c.curatingACL.GetEventCount(pubkeyHex, today)
 253  	if err != nil {
 254  		log.W.F("curating ACL: failed to get event count: %v", err)
 255  		count = 0
 256  	}
 257  
 258  	if count >= dailyLimit {
 259  		return false, errorf.E("rate limit exceeded: maximum %d events per day for unclassified users", dailyLimit)
 260  	}
 261  
 262  	// Increment the counter
 263  	_, err = c.curatingACL.IncrementEventCount(pubkeyHex, today)
 264  	if err != nil {
 265  		log.W.F("curating ACL: failed to increment event count: %v", err)
 266  	}
 267  
 268  	return true, nil
 269  }
 270  
 271  // RateLimitCheck checks if an unclassified user can publish and handles IP tracking
 272  // This is called separately when we have access to the IP address
 273  func (c *Curating) RateLimitCheck(pubkeyHex, ip string) (allowed bool, message string, err error) {
 274  	config, err := c.GetConfig()
 275  	if err != nil {
 276  		return false, "", errorf.E("failed to get config: %v", err)
 277  	}
 278  
 279  	today := time.Now().Format("2006-01-02")
 280  
 281  	// Check IP flood limit first (applies to all non-trusted users from this IP)
 282  	if ip != "" {
 283  		ipDailyLimit := config.IPDailyLimit
 284  		if ipDailyLimit == 0 {
 285  			ipDailyLimit = DefaultIPDailyLimit
 286  		}
 287  
 288  		ipCount, err := c.curatingACL.GetIPEventCount(ip, today)
 289  		if err != nil {
 290  			ipCount = 0
 291  		}
 292  
 293  		if ipCount >= ipDailyLimit {
 294  			// IP has exceeded flood limit - record offense and ban
 295  			c.recordIPOffenseAndBan(ip, pubkeyHex, config, "IP flood limit exceeded")
 296  			return false, "rate limit exceeded: too many events from this IP address", nil
 297  		}
 298  	}
 299  
 300  	// Check per-pubkey daily limit
 301  	dailyLimit := config.DailyLimit
 302  	if dailyLimit == 0 {
 303  		dailyLimit = DefaultDailyLimit
 304  	}
 305  
 306  	count, err := c.curatingACL.GetEventCount(pubkeyHex, today)
 307  	if err != nil {
 308  		count = 0
 309  	}
 310  
 311  	if count >= dailyLimit {
 312  		// Record IP offense and potentially ban
 313  		if ip != "" {
 314  			c.recordIPOffenseAndBan(ip, pubkeyHex, config, "pubkey rate limit exceeded")
 315  		}
 316  		return false, "rate limit exceeded: maximum events per day for unclassified users", nil
 317  	}
 318  
 319  	// Increment IP event count for flood tracking (only for non-trusted users)
 320  	if ip != "" {
 321  		_, _ = c.curatingACL.IncrementIPEventCount(ip, today)
 322  	}
 323  
 324  	return true, "", nil
 325  }
 326  
 327  // recordIPOffenseAndBan records an offense for an IP and applies a ban if warranted
 328  func (c *Curating) recordIPOffenseAndBan(ip, pubkeyHex string, config database.CuratingConfig, reason string) {
 329  	offenseCount, _ := c.curatingACL.RecordIPOffense(ip, pubkeyHex)
 330  	if offenseCount > 0 {
 331  		firstBanHours := config.FirstBanHours
 332  		if firstBanHours == 0 {
 333  			firstBanHours = DefaultFirstBanHours
 334  		}
 335  		secondBanHours := config.SecondBanHours
 336  		if secondBanHours == 0 {
 337  			secondBanHours = DefaultSecondBanHours
 338  		}
 339  
 340  		var banDuration time.Duration
 341  		if offenseCount >= 2 {
 342  			banDuration = time.Duration(secondBanHours) * time.Hour
 343  			log.W.F("curating ACL: IP %s banned for %d hours (offense #%d, reason: %s)", ip, secondBanHours, offenseCount, reason)
 344  		} else {
 345  			banDuration = time.Duration(firstBanHours) * time.Hour
 346  			log.W.F("curating ACL: IP %s banned for %d hours (offense #%d, reason: %s)", ip, firstBanHours, offenseCount, reason)
 347  		}
 348  		c.curatingACL.BlockIP(ip, banDuration, reason)
 349  	}
 350  }
 351  
 352  func (c *Curating) GetACLInfo() (name, description, documentation string) {
 353  	return "curating", "curated relay with rate-limited unclassified publishers",
 354  		`Curating ACL mode provides three-tier publisher classification:
 355  
 356  - Trusted: Unlimited publishing, explicitly marked by admin
 357  - Blacklisted: Cannot publish, events rejected
 358  - Unclassified: Default state, rate-limited (default 50 events/day)
 359  
 360  Features:
 361  - Per-pubkey daily rate limiting for unclassified users (default 50/day)
 362  - Per-IP daily rate limiting for flood protection (default 500/day)
 363  - IP-based spam detection (tracks multiple rate-limited pubkeys)
 364  - Automatic IP bans (1-hour first offense, 1-week second offense)
 365  - Event kind allow-listing for content control
 366  - Spam flagging (events hidden from queries without deletion)
 367  
 368  Configuration via kind 30078 event with d-tag "curating-config".
 369  The relay will not accept events until configured.
 370  
 371  Management through NIP-86 API endpoints:
 372  - trustpubkey, untrustpubkey, listtrustedpubkeys
 373  - blacklistpubkey, unblacklistpubkey, listblacklistedpubkeys
 374  - listunclassifiedusers
 375  - markspam, unmarkspam, listspamevents
 376  - setallowedkindcategories, getallowedkindcategories`
 377  }
 378  
 379  func (c *Curating) Type() string { return "curating" }
 380  
 381  // IsEventVisible checks if an event should be visible to the given access level.
 382  // Events from blacklisted pubkeys are only visible to admin/owner.
 383  func (c *Curating) IsEventVisible(ev *event.E, accessLevel string) bool {
 384  	// Admin and owner can see all events
 385  	if accessLevel == "admin" || accessLevel == "owner" {
 386  		return true
 387  	}
 388  
 389  	// Check if the event author is blacklisted
 390  	pubkeyHex := hex.EncodeToString(ev.Pubkey)
 391  
 392  	// Check cache first
 393  	c.cacheMx.RLock()
 394  	isBlacklisted := c.blacklistedCache[pubkeyHex]
 395  	c.cacheMx.RUnlock()
 396  
 397  	if isBlacklisted {
 398  		return false
 399  	}
 400  
 401  	// Check database if not in cache
 402  	if blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex); blacklisted {
 403  		c.cacheMx.Lock()
 404  		c.blacklistedCache[pubkeyHex] = true
 405  		c.cacheMx.Unlock()
 406  		return false
 407  	}
 408  
 409  	return true
 410  }
 411  
 412  // FilterVisibleEvents filters a list of events, removing those from blacklisted pubkeys.
 413  // Returns only events visible to the given access level.
 414  func (c *Curating) FilterVisibleEvents(events []*event.E, accessLevel string) []*event.E {
 415  	// Admin and owner can see all events
 416  	if accessLevel == "admin" || accessLevel == "owner" {
 417  		return events
 418  	}
 419  
 420  	// Filter out events from blacklisted pubkeys
 421  	visible := make([]*event.E, 0, len(events))
 422  	for _, ev := range events {
 423  		if c.IsEventVisible(ev, accessLevel) {
 424  			visible = append(visible, ev)
 425  		}
 426  	}
 427  	return visible
 428  }
 429  
 430  // GetCuratingACL returns the database ACL instance for direct access
 431  func (c *Curating) GetCuratingACL() *database.CuratingACL {
 432  	return c.curatingACL
 433  }
 434  
 435  func (c *Curating) Syncer() {
 436  	log.I.F("starting curating ACL syncer")
 437  
 438  	// Start background cleanup goroutine
 439  	go c.backgroundCleanup()
 440  }
 441  
 442  // backgroundCleanup periodically cleans up expired data
 443  func (c *Curating) backgroundCleanup() {
 444  	// Run cleanup every hour
 445  	ticker := time.NewTicker(time.Hour)
 446  	defer ticker.Stop()
 447  
 448  	for {
 449  		select {
 450  		case <-c.Ctx.Done():
 451  			log.D.F("curating ACL background cleanup stopped")
 452  			return
 453  		case <-ticker.C:
 454  			c.runCleanup()
 455  		}
 456  	}
 457  }
 458  
 459  func (c *Curating) runCleanup() {
 460  	log.D.F("curating ACL: running background cleanup")
 461  
 462  	// Clean up expired IP blocks
 463  	if err := c.curatingACL.CleanupExpiredIPBlocks(); err != nil {
 464  		log.W.F("curating ACL: failed to cleanup expired IP blocks: %v", err)
 465  	}
 466  
 467  	// Clean up old event counts (older than 7 days)
 468  	cutoffDate := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
 469  	if err := c.curatingACL.CleanupOldEventCounts(cutoffDate); err != nil {
 470  		log.W.F("curating ACL: failed to cleanup old event counts: %v", err)
 471  	}
 472  
 473  	// Refresh caches
 474  	if err := c.RefreshCaches(); err != nil {
 475  		log.W.F("curating ACL: failed to refresh caches: %v", err)
 476  	}
 477  }
 478  
 479  // RefreshCaches refreshes all in-memory caches from the database
 480  func (c *Curating) RefreshCaches() error {
 481  	c.cacheMx.Lock()
 482  	defer c.cacheMx.Unlock()
 483  
 484  	// Refresh trusted pubkeys cache
 485  	trusted, err := c.curatingACL.ListTrustedPubkeys()
 486  	if err != nil {
 487  		return errorf.E("failed to list trusted pubkeys: %v", err)
 488  	}
 489  	c.trustedCache = make(map[string]bool)
 490  	for _, t := range trusted {
 491  		c.trustedCache[t.Pubkey] = true
 492  	}
 493  
 494  	// Refresh blacklisted pubkeys cache
 495  	blacklisted, err := c.curatingACL.ListBlacklistedPubkeys()
 496  	if err != nil {
 497  		return errorf.E("failed to list blacklisted pubkeys: %v", err)
 498  	}
 499  	c.blacklistedCache = make(map[string]bool)
 500  	for _, b := range blacklisted {
 501  		c.blacklistedCache[b.Pubkey] = true
 502  	}
 503  
 504  	// Refresh config cache
 505  	config, err := c.curatingACL.GetConfig()
 506  	if err != nil {
 507  		return errorf.E("failed to get config: %v", err)
 508  	}
 509  	c.configCache = &config
 510  
 511  	// Refresh allowed kinds cache
 512  	c.kindCache = make(map[int]bool)
 513  	for _, k := range config.AllowedKinds {
 514  		c.kindCache[k] = true
 515  	}
 516  
 517  	log.D.F("curating ACL: caches refreshed - %d trusted, %d blacklisted, %d allowed kinds",
 518  		len(c.trustedCache), len(c.blacklistedCache), len(c.kindCache))
 519  
 520  	return nil
 521  }
 522  
 523  // GetConfig returns the current configuration
 524  func (c *Curating) GetConfig() (database.CuratingConfig, error) {
 525  	c.cacheMx.RLock()
 526  	if c.configCache != nil {
 527  		config := *c.configCache
 528  		c.cacheMx.RUnlock()
 529  		return config, nil
 530  	}
 531  	c.cacheMx.RUnlock()
 532  
 533  	return c.curatingACL.GetConfig()
 534  }
 535  
 536  // IsConfigured returns true if the relay has been configured
 537  func (c *Curating) IsConfigured() (bool, error) {
 538  	return c.curatingACL.IsConfigured()
 539  }
 540  
 541  // ProcessConfigEvent processes a kind 30078 event to extract curating configuration
 542  func (c *Curating) ProcessConfigEvent(ev *event.E) error {
 543  	if ev.Kind != CuratingConfigKind {
 544  		return errorf.E("invalid event kind: expected %d, got %d", CuratingConfigKind, ev.Kind)
 545  	}
 546  
 547  	// Check d-tag
 548  	dTag := ev.Tags.GetFirst([]byte("d"))
 549  	if dTag == nil || string(dTag.Value()) != CuratingConfigDTag {
 550  		return errorf.E("invalid d-tag: expected %s", CuratingConfigDTag)
 551  	}
 552  
 553  	// Check if pubkey is owner or admin
 554  	pubkeyHex := hex.EncodeToString(ev.Pubkey)
 555  	isOwner := false
 556  	isAdmin := false
 557  	for _, v := range c.owners {
 558  		if utils.FastEqual(v, ev.Pubkey) {
 559  			isOwner = true
 560  			break
 561  		}
 562  	}
 563  	if !isOwner {
 564  		for _, v := range c.admins {
 565  			if utils.FastEqual(v, ev.Pubkey) {
 566  				isAdmin = true
 567  				break
 568  			}
 569  		}
 570  	}
 571  	if !isOwner && !isAdmin {
 572  		return errorf.E("config event must be from owner or admin")
 573  	}
 574  
 575  	// Parse configuration from tags
 576  	config := database.CuratingConfig{
 577  		ConfigEventID: hex.EncodeToString(ev.ID[:]),
 578  		ConfigPubkey:  pubkeyHex,
 579  		ConfiguredAt:  ev.CreatedAt,
 580  		DailyLimit:    DefaultDailyLimit,
 581  		FirstBanHours: DefaultFirstBanHours,
 582  		SecondBanHours: DefaultSecondBanHours,
 583  	}
 584  
 585  	for _, tag := range *ev.Tags {
 586  		if tag.Len() < 2 {
 587  			continue
 588  		}
 589  		key := string(tag.Key())
 590  		value := string(tag.Value())
 591  
 592  		switch key {
 593  		case "daily_limit":
 594  			if v, err := strconv.Atoi(value); err == nil && v > 0 {
 595  				config.DailyLimit = v
 596  			}
 597  		case "ip_daily_limit":
 598  			if v, err := strconv.Atoi(value); err == nil && v > 0 {
 599  				config.IPDailyLimit = v
 600  			}
 601  		case "first_ban_hours":
 602  			if v, err := strconv.Atoi(value); err == nil && v > 0 {
 603  				config.FirstBanHours = v
 604  			}
 605  		case "second_ban_hours":
 606  			if v, err := strconv.Atoi(value); err == nil && v > 0 {
 607  				config.SecondBanHours = v
 608  			}
 609  		case "kind_category":
 610  			config.KindCategories = append(config.KindCategories, value)
 611  		case "kind_range":
 612  			config.AllowedRanges = append(config.AllowedRanges, value)
 613  		case "kind":
 614  			if k, err := strconv.Atoi(value); err == nil {
 615  				config.AllowedKinds = append(config.AllowedKinds, k)
 616  			}
 617  		}
 618  	}
 619  
 620  	// Save configuration
 621  	if err := c.curatingACL.SaveConfig(config); err != nil {
 622  		return errorf.E("failed to save config: %v", err)
 623  	}
 624  
 625  	// Refresh caches
 626  	c.cacheMx.Lock()
 627  	c.configCache = &config
 628  	c.cacheMx.Unlock()
 629  
 630  	log.I.F("curating ACL: configuration updated from event %s by %s",
 631  		config.ConfigEventID, config.ConfigPubkey)
 632  
 633  	return nil
 634  }
 635  
 636  // IsTrusted checks if a pubkey is trusted
 637  func (c *Curating) IsTrusted(pubkeyHex string) bool {
 638  	c.cacheMx.RLock()
 639  	if c.trustedCache[pubkeyHex] {
 640  		c.cacheMx.RUnlock()
 641  		return true
 642  	}
 643  	c.cacheMx.RUnlock()
 644  
 645  	trusted, _ := c.curatingACL.IsPubkeyTrusted(pubkeyHex)
 646  	return trusted
 647  }
 648  
 649  // IsBlacklisted checks if a pubkey is blacklisted
 650  func (c *Curating) IsBlacklisted(pubkeyHex string) bool {
 651  	c.cacheMx.RLock()
 652  	if c.blacklistedCache[pubkeyHex] {
 653  		c.cacheMx.RUnlock()
 654  		return true
 655  	}
 656  	c.cacheMx.RUnlock()
 657  
 658  	blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex)
 659  	return blacklisted
 660  }
 661  
 662  // TrustPubkey adds a pubkey to the trusted list
 663  func (c *Curating) TrustPubkey(pubkeyHex, note string) error {
 664  	pubkeyHex = strings.ToLower(pubkeyHex)
 665  	if err := c.curatingACL.SaveTrustedPubkey(pubkeyHex, note); err != nil {
 666  		return err
 667  	}
 668  	// Update cache
 669  	c.cacheMx.Lock()
 670  	c.trustedCache[pubkeyHex] = true
 671  	delete(c.blacklistedCache, pubkeyHex) // Remove from blacklist cache if present
 672  	c.cacheMx.Unlock()
 673  	// Also remove from blacklist in DB
 674  	c.curatingACL.RemoveBlacklistedPubkey(pubkeyHex)
 675  	return nil
 676  }
 677  
 678  // UntrustPubkey removes a pubkey from the trusted list
 679  func (c *Curating) UntrustPubkey(pubkeyHex string) error {
 680  	pubkeyHex = strings.ToLower(pubkeyHex)
 681  	if err := c.curatingACL.RemoveTrustedPubkey(pubkeyHex); err != nil {
 682  		return err
 683  	}
 684  	// Update cache
 685  	c.cacheMx.Lock()
 686  	delete(c.trustedCache, pubkeyHex)
 687  	c.cacheMx.Unlock()
 688  	return nil
 689  }
 690  
 691  // BlacklistPubkey adds a pubkey to the blacklist
 692  func (c *Curating) BlacklistPubkey(pubkeyHex, reason string) error {
 693  	pubkeyHex = strings.ToLower(pubkeyHex)
 694  	if err := c.curatingACL.SaveBlacklistedPubkey(pubkeyHex, reason); err != nil {
 695  		return err
 696  	}
 697  	// Update cache
 698  	c.cacheMx.Lock()
 699  	c.blacklistedCache[pubkeyHex] = true
 700  	delete(c.trustedCache, pubkeyHex) // Remove from trusted cache if present
 701  	c.cacheMx.Unlock()
 702  	// Also remove from trusted list in DB
 703  	c.curatingACL.RemoveTrustedPubkey(pubkeyHex)
 704  	return nil
 705  }
 706  
 707  // UnblacklistPubkey removes a pubkey from the blacklist
 708  func (c *Curating) UnblacklistPubkey(pubkeyHex string) error {
 709  	pubkeyHex = strings.ToLower(pubkeyHex)
 710  	if err := c.curatingACL.RemoveBlacklistedPubkey(pubkeyHex); err != nil {
 711  		return err
 712  	}
 713  	// Update cache
 714  	c.cacheMx.Lock()
 715  	delete(c.blacklistedCache, pubkeyHex)
 716  	c.cacheMx.Unlock()
 717  	return nil
 718  }
 719  
 720  func init() {
 721  	Registry.Register(new(Curating))
 722  }
 723