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