package acl import ( "context" "encoding/hex" "reflect" "strconv" "strings" "sync" "time" "lol.mleku.dev/chk" "lol.mleku.dev/errorf" "lol.mleku.dev/log" "next.orly.dev/app/config" "next.orly.dev/pkg/database" "git.mleku.dev/mleku/nostr/encoders/bech32encoding" "git.mleku.dev/mleku/nostr/encoders/event" "next.orly.dev/pkg/utils" ) // Default values for curating mode const ( DefaultDailyLimit = 50 DefaultIPDailyLimit = 500 // Max events per IP per day (flood protection) DefaultFirstBanHours = 1 DefaultSecondBanHours = 168 // 1 week CuratingConfigKind = 30078 CuratingConfigDTag = "curating-config" ) // Curating implements the curating ACL mode with three-tier publisher classification: // - Trusted: Unlimited publishing // - Blacklisted: Cannot publish // - Unclassified: Rate-limited publishing (default 50/day) type Curating struct { Ctx context.Context cfg *config.C db *database.D curatingACL *database.CuratingACL owners [][]byte admins [][]byte mx sync.RWMutex // In-memory caches for performance trustedCache map[string]bool blacklistedCache map[string]bool kindCache map[int]bool configCache *database.CuratingConfig cacheMx sync.RWMutex } func (c *Curating) Configure(cfg ...any) (err error) { log.I.F("configuring curating ACL") for _, ca := range cfg { switch cv := ca.(type) { case *config.C: c.cfg = cv case *database.D: c.db = cv c.curatingACL = database.NewCuratingACL(cv) case context.Context: c.Ctx = cv default: err = errorf.E("invalid type: %T", reflect.TypeOf(ca)) } } if c.cfg == nil || c.db == nil { err = errorf.E("both config and database must be set") return } // Initialize caches c.trustedCache = make(map[string]bool) c.blacklistedCache = make(map[string]bool) c.kindCache = make(map[int]bool) // Load owners from config for _, owner := range c.cfg.Owners { var own []byte if o, e := bech32encoding.NpubOrHexToPublicKeyBinary(owner); chk.E(e) { continue } else { own = o } c.owners = append(c.owners, own) } // Load admins from config for _, admin := range c.cfg.Admins { var adm []byte if a, e := bech32encoding.NpubOrHexToPublicKeyBinary(admin); chk.E(e) { continue } else { adm = a } c.admins = append(c.admins, adm) } // Refresh caches from database if err = c.RefreshCaches(); err != nil { log.W.F("curating ACL: failed to refresh caches: %v", err) } return nil } func (c *Curating) GetAccessLevel(pub []byte, address string) (level string) { c.mx.RLock() defer c.mx.RUnlock() pubkeyHex := hex.EncodeToString(pub) // Check owners first for _, v := range c.owners { if utils.FastEqual(v, pub) { return "owner" } } // Check admins for _, v := range c.admins { if utils.FastEqual(v, pub) { return "admin" } } // Check if IP is blocked if address != "" { blocked, _, err := c.curatingACL.IsIPBlocked(address) if err == nil && blocked { return "blocked" } } // Check if pubkey is blacklisted (check cache first) c.cacheMx.RLock() if c.blacklistedCache[pubkeyHex] { c.cacheMx.RUnlock() return "banned" } c.cacheMx.RUnlock() // Double-check database for blacklisted blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex) if blacklisted { // Update cache c.cacheMx.Lock() c.blacklistedCache[pubkeyHex] = true c.cacheMx.Unlock() return "banned" } // All other users get write access (rate limiting handled in CheckPolicy) return "write" } // CheckPolicy implements the PolicyChecker interface for event-level filtering func (c *Curating) CheckPolicy(ev *event.E) (allowed bool, err error) { pubkeyHex := hex.EncodeToString(ev.Pubkey) // Check if configured config, err := c.GetConfig() if err != nil { return false, errorf.E("failed to get config: %v", err) } if config.ConfigEventID == "" { return false, errorf.E("curating mode not configured: please publish a configuration event") } // Check if event is spam-flagged isSpam, _ := c.curatingACL.IsEventSpam(hex.EncodeToString(ev.ID[:])) if isSpam { return false, errorf.E("blocked: event is flagged as spam") } // Check if event kind is allowed if !c.curatingACL.IsKindAllowed(int(ev.Kind), &config) { return false, errorf.E("blocked: event kind %d is not in the allow list", ev.Kind) } // Check if pubkey is blacklisted c.cacheMx.RLock() isBlacklisted := c.blacklistedCache[pubkeyHex] c.cacheMx.RUnlock() if !isBlacklisted { isBlacklisted, _ = c.curatingACL.IsPubkeyBlacklisted(pubkeyHex) } if isBlacklisted { return false, errorf.E("blocked: pubkey is blacklisted") } // Check if pubkey is trusted (bypass rate limiting) c.cacheMx.RLock() isTrusted := c.trustedCache[pubkeyHex] c.cacheMx.RUnlock() if !isTrusted { isTrusted, _ = c.curatingACL.IsPubkeyTrusted(pubkeyHex) if isTrusted { // Update cache c.cacheMx.Lock() c.trustedCache[pubkeyHex] = true c.cacheMx.Unlock() } } if isTrusted { return true, nil } // Check if owner or admin (bypass rate limiting) for _, v := range c.owners { if utils.FastEqual(v, ev.Pubkey) { return true, nil } } for _, v := range c.admins { if utils.FastEqual(v, ev.Pubkey) { return true, nil } } // For unclassified users, check rate limit today := time.Now().Format("2006-01-02") dailyLimit := config.DailyLimit if dailyLimit == 0 { dailyLimit = DefaultDailyLimit } count, err := c.curatingACL.GetEventCount(pubkeyHex, today) if err != nil { log.W.F("curating ACL: failed to get event count: %v", err) count = 0 } if count >= dailyLimit { return false, errorf.E("rate limit exceeded: maximum %d events per day for unclassified users", dailyLimit) } // Increment the counter _, err = c.curatingACL.IncrementEventCount(pubkeyHex, today) if err != nil { log.W.F("curating ACL: failed to increment event count: %v", err) } return true, nil } // RateLimitCheck checks if an unclassified user can publish and handles IP tracking // This is called separately when we have access to the IP address func (c *Curating) RateLimitCheck(pubkeyHex, ip string) (allowed bool, message string, err error) { config, err := c.GetConfig() if err != nil { return false, "", errorf.E("failed to get config: %v", err) } today := time.Now().Format("2006-01-02") // Check IP flood limit first (applies to all non-trusted users from this IP) if ip != "" { ipDailyLimit := config.IPDailyLimit if ipDailyLimit == 0 { ipDailyLimit = DefaultIPDailyLimit } ipCount, err := c.curatingACL.GetIPEventCount(ip, today) if err != nil { ipCount = 0 } if ipCount >= ipDailyLimit { // IP has exceeded flood limit - record offense and ban c.recordIPOffenseAndBan(ip, pubkeyHex, config, "IP flood limit exceeded") return false, "rate limit exceeded: too many events from this IP address", nil } } // Check per-pubkey daily limit dailyLimit := config.DailyLimit if dailyLimit == 0 { dailyLimit = DefaultDailyLimit } count, err := c.curatingACL.GetEventCount(pubkeyHex, today) if err != nil { count = 0 } if count >= dailyLimit { // Record IP offense and potentially ban if ip != "" { c.recordIPOffenseAndBan(ip, pubkeyHex, config, "pubkey rate limit exceeded") } return false, "rate limit exceeded: maximum events per day for unclassified users", nil } // Increment IP event count for flood tracking (only for non-trusted users) if ip != "" { _, _ = c.curatingACL.IncrementIPEventCount(ip, today) } return true, "", nil } // recordIPOffenseAndBan records an offense for an IP and applies a ban if warranted func (c *Curating) recordIPOffenseAndBan(ip, pubkeyHex string, config database.CuratingConfig, reason string) { offenseCount, _ := c.curatingACL.RecordIPOffense(ip, pubkeyHex) if offenseCount > 0 { firstBanHours := config.FirstBanHours if firstBanHours == 0 { firstBanHours = DefaultFirstBanHours } secondBanHours := config.SecondBanHours if secondBanHours == 0 { secondBanHours = DefaultSecondBanHours } var banDuration time.Duration if offenseCount >= 2 { banDuration = time.Duration(secondBanHours) * time.Hour log.W.F("curating ACL: IP %s banned for %d hours (offense #%d, reason: %s)", ip, secondBanHours, offenseCount, reason) } else { banDuration = time.Duration(firstBanHours) * time.Hour log.W.F("curating ACL: IP %s banned for %d hours (offense #%d, reason: %s)", ip, firstBanHours, offenseCount, reason) } c.curatingACL.BlockIP(ip, banDuration, reason) } } func (c *Curating) GetACLInfo() (name, description, documentation string) { return "curating", "curated relay with rate-limited unclassified publishers", `Curating ACL mode provides three-tier publisher classification: - Trusted: Unlimited publishing, explicitly marked by admin - Blacklisted: Cannot publish, events rejected - Unclassified: Default state, rate-limited (default 50 events/day) Features: - Per-pubkey daily rate limiting for unclassified users (default 50/day) - Per-IP daily rate limiting for flood protection (default 500/day) - IP-based spam detection (tracks multiple rate-limited pubkeys) - Automatic IP bans (1-hour first offense, 1-week second offense) - Event kind allow-listing for content control - Spam flagging (events hidden from queries without deletion) Configuration via kind 30078 event with d-tag "curating-config". The relay will not accept events until configured. Management through NIP-86 API endpoints: - trustpubkey, untrustpubkey, listtrustedpubkeys - blacklistpubkey, unblacklistpubkey, listblacklistedpubkeys - listunclassifiedusers - markspam, unmarkspam, listspamevents - setallowedkindcategories, getallowedkindcategories` } func (c *Curating) Type() string { return "curating" } // IsEventVisible checks if an event should be visible to the given access level. // Events from blacklisted pubkeys are only visible to admin/owner. func (c *Curating) IsEventVisible(ev *event.E, accessLevel string) bool { // Admin and owner can see all events if accessLevel == "admin" || accessLevel == "owner" { return true } // Check if the event author is blacklisted pubkeyHex := hex.EncodeToString(ev.Pubkey) // Check cache first c.cacheMx.RLock() isBlacklisted := c.blacklistedCache[pubkeyHex] c.cacheMx.RUnlock() if isBlacklisted { return false } // Check database if not in cache if blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex); blacklisted { c.cacheMx.Lock() c.blacklistedCache[pubkeyHex] = true c.cacheMx.Unlock() return false } return true } // FilterVisibleEvents filters a list of events, removing those from blacklisted pubkeys. // Returns only events visible to the given access level. func (c *Curating) FilterVisibleEvents(events []*event.E, accessLevel string) []*event.E { // Admin and owner can see all events if accessLevel == "admin" || accessLevel == "owner" { return events } // Filter out events from blacklisted pubkeys visible := make([]*event.E, 0, len(events)) for _, ev := range events { if c.IsEventVisible(ev, accessLevel) { visible = append(visible, ev) } } return visible } // GetCuratingACL returns the database ACL instance for direct access func (c *Curating) GetCuratingACL() *database.CuratingACL { return c.curatingACL } func (c *Curating) Syncer() { log.I.F("starting curating ACL syncer") // Start background cleanup goroutine go c.backgroundCleanup() } // backgroundCleanup periodically cleans up expired data func (c *Curating) backgroundCleanup() { // Run cleanup every hour ticker := time.NewTicker(time.Hour) defer ticker.Stop() for { select { case <-c.Ctx.Done(): log.D.F("curating ACL background cleanup stopped") return case <-ticker.C: c.runCleanup() } } } func (c *Curating) runCleanup() { log.D.F("curating ACL: running background cleanup") // Clean up expired IP blocks if err := c.curatingACL.CleanupExpiredIPBlocks(); err != nil { log.W.F("curating ACL: failed to cleanup expired IP blocks: %v", err) } // Clean up old event counts (older than 7 days) cutoffDate := time.Now().AddDate(0, 0, -7).Format("2006-01-02") if err := c.curatingACL.CleanupOldEventCounts(cutoffDate); err != nil { log.W.F("curating ACL: failed to cleanup old event counts: %v", err) } // Refresh caches if err := c.RefreshCaches(); err != nil { log.W.F("curating ACL: failed to refresh caches: %v", err) } } // RefreshCaches refreshes all in-memory caches from the database func (c *Curating) RefreshCaches() error { c.cacheMx.Lock() defer c.cacheMx.Unlock() // Refresh trusted pubkeys cache trusted, err := c.curatingACL.ListTrustedPubkeys() if err != nil { return errorf.E("failed to list trusted pubkeys: %v", err) } c.trustedCache = make(map[string]bool) for _, t := range trusted { c.trustedCache[t.Pubkey] = true } // Refresh blacklisted pubkeys cache blacklisted, err := c.curatingACL.ListBlacklistedPubkeys() if err != nil { return errorf.E("failed to list blacklisted pubkeys: %v", err) } c.blacklistedCache = make(map[string]bool) for _, b := range blacklisted { c.blacklistedCache[b.Pubkey] = true } // Refresh config cache config, err := c.curatingACL.GetConfig() if err != nil { return errorf.E("failed to get config: %v", err) } c.configCache = &config // Refresh allowed kinds cache c.kindCache = make(map[int]bool) for _, k := range config.AllowedKinds { c.kindCache[k] = true } log.D.F("curating ACL: caches refreshed - %d trusted, %d blacklisted, %d allowed kinds", len(c.trustedCache), len(c.blacklistedCache), len(c.kindCache)) return nil } // GetConfig returns the current configuration func (c *Curating) GetConfig() (database.CuratingConfig, error) { c.cacheMx.RLock() if c.configCache != nil { config := *c.configCache c.cacheMx.RUnlock() return config, nil } c.cacheMx.RUnlock() return c.curatingACL.GetConfig() } // IsConfigured returns true if the relay has been configured func (c *Curating) IsConfigured() (bool, error) { return c.curatingACL.IsConfigured() } // ProcessConfigEvent processes a kind 30078 event to extract curating configuration func (c *Curating) ProcessConfigEvent(ev *event.E) error { if ev.Kind != CuratingConfigKind { return errorf.E("invalid event kind: expected %d, got %d", CuratingConfigKind, ev.Kind) } // Check d-tag dTag := ev.Tags.GetFirst([]byte("d")) if dTag == nil || string(dTag.Value()) != CuratingConfigDTag { return errorf.E("invalid d-tag: expected %s", CuratingConfigDTag) } // Check if pubkey is owner or admin pubkeyHex := hex.EncodeToString(ev.Pubkey) isOwner := false isAdmin := false for _, v := range c.owners { if utils.FastEqual(v, ev.Pubkey) { isOwner = true break } } if !isOwner { for _, v := range c.admins { if utils.FastEqual(v, ev.Pubkey) { isAdmin = true break } } } if !isOwner && !isAdmin { return errorf.E("config event must be from owner or admin") } // Parse configuration from tags config := database.CuratingConfig{ ConfigEventID: hex.EncodeToString(ev.ID[:]), ConfigPubkey: pubkeyHex, ConfiguredAt: ev.CreatedAt, DailyLimit: DefaultDailyLimit, FirstBanHours: DefaultFirstBanHours, SecondBanHours: DefaultSecondBanHours, } for _, tag := range *ev.Tags { if tag.Len() < 2 { continue } key := string(tag.Key()) value := string(tag.Value()) switch key { case "daily_limit": if v, err := strconv.Atoi(value); err == nil && v > 0 { config.DailyLimit = v } case "ip_daily_limit": if v, err := strconv.Atoi(value); err == nil && v > 0 { config.IPDailyLimit = v } case "first_ban_hours": if v, err := strconv.Atoi(value); err == nil && v > 0 { config.FirstBanHours = v } case "second_ban_hours": if v, err := strconv.Atoi(value); err == nil && v > 0 { config.SecondBanHours = v } case "kind_category": config.KindCategories = append(config.KindCategories, value) case "kind_range": config.AllowedRanges = append(config.AllowedRanges, value) case "kind": if k, err := strconv.Atoi(value); err == nil { config.AllowedKinds = append(config.AllowedKinds, k) } } } // Save configuration if err := c.curatingACL.SaveConfig(config); err != nil { return errorf.E("failed to save config: %v", err) } // Refresh caches c.cacheMx.Lock() c.configCache = &config c.cacheMx.Unlock() log.I.F("curating ACL: configuration updated from event %s by %s", config.ConfigEventID, config.ConfigPubkey) return nil } // IsTrusted checks if a pubkey is trusted func (c *Curating) IsTrusted(pubkeyHex string) bool { c.cacheMx.RLock() if c.trustedCache[pubkeyHex] { c.cacheMx.RUnlock() return true } c.cacheMx.RUnlock() trusted, _ := c.curatingACL.IsPubkeyTrusted(pubkeyHex) return trusted } // IsBlacklisted checks if a pubkey is blacklisted func (c *Curating) IsBlacklisted(pubkeyHex string) bool { c.cacheMx.RLock() if c.blacklistedCache[pubkeyHex] { c.cacheMx.RUnlock() return true } c.cacheMx.RUnlock() blacklisted, _ := c.curatingACL.IsPubkeyBlacklisted(pubkeyHex) return blacklisted } // TrustPubkey adds a pubkey to the trusted list func (c *Curating) TrustPubkey(pubkeyHex, note string) error { pubkeyHex = strings.ToLower(pubkeyHex) if err := c.curatingACL.SaveTrustedPubkey(pubkeyHex, note); err != nil { return err } // Update cache c.cacheMx.Lock() c.trustedCache[pubkeyHex] = true delete(c.blacklistedCache, pubkeyHex) // Remove from blacklist cache if present c.cacheMx.Unlock() // Also remove from blacklist in DB c.curatingACL.RemoveBlacklistedPubkey(pubkeyHex) return nil } // UntrustPubkey removes a pubkey from the trusted list func (c *Curating) UntrustPubkey(pubkeyHex string) error { pubkeyHex = strings.ToLower(pubkeyHex) if err := c.curatingACL.RemoveTrustedPubkey(pubkeyHex); err != nil { return err } // Update cache c.cacheMx.Lock() delete(c.trustedCache, pubkeyHex) c.cacheMx.Unlock() return nil } // BlacklistPubkey adds a pubkey to the blacklist func (c *Curating) BlacklistPubkey(pubkeyHex, reason string) error { pubkeyHex = strings.ToLower(pubkeyHex) if err := c.curatingACL.SaveBlacklistedPubkey(pubkeyHex, reason); err != nil { return err } // Update cache c.cacheMx.Lock() c.blacklistedCache[pubkeyHex] = true delete(c.trustedCache, pubkeyHex) // Remove from trusted cache if present c.cacheMx.Unlock() // Also remove from trusted list in DB c.curatingACL.RemoveTrustedPubkey(pubkeyHex) return nil } // UnblacklistPubkey removes a pubkey from the blacklist func (c *Curating) UnblacklistPubkey(pubkeyHex string) error { pubkeyHex = strings.ToLower(pubkeyHex) if err := c.curatingACL.RemoveBlacklistedPubkey(pubkeyHex); err != nil { return err } // Update cache c.cacheMx.Lock() delete(c.blacklistedCache, pubkeyHex) c.cacheMx.Unlock() return nil } func init() { Registry.Register(new(Curating)) }