You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

699 lines
18 KiB

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))
}