paid.go raw

   1  //go:build !(js && wasm)
   2  
   3  package acl
   4  
   5  import (
   6  	"context"
   7  	"encoding/hex"
   8  	"sync"
   9  	"time"
  10  
  11  	"next.orly.dev/pkg/lol/chk"
  12  	"next.orly.dev/pkg/lol/errorf"
  13  	"next.orly.dev/pkg/lol/log"
  14  	"next.orly.dev/app/config"
  15  	"next.orly.dev/pkg/database"
  16  
  17  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  18  )
  19  
  20  // Paid implements a Lightning payment-gated ACL.
  21  // Active subscribers get "write" access to the relay and can send email
  22  // through the bridge. Owners and admins bypass payment requirements.
  23  type Paid struct {
  24  	Ctx context.Context
  25  	cfg *config.C
  26  	db  database.Database
  27  
  28  	mx       sync.RWMutex
  29  	owners   [][]byte
  30  	admins   [][]byte
  31  	ownerSet map[string]struct{}
  32  	adminSet map[string]struct{}
  33  
  34  	// activeSet maps pubkey hex → subscription expiry for O(1) GetAccessLevel.
  35  	activeSet map[string]time.Time
  36  }
  37  
  38  func (p *Paid) Configure(cfg ...any) (err error) {
  39  	log.I.F("configuring paid ACL")
  40  	for _, ca := range cfg {
  41  		switch c := ca.(type) {
  42  		case *config.C:
  43  			p.cfg = c
  44  		case database.Database:
  45  			p.db = c
  46  		case context.Context:
  47  			p.Ctx = c
  48  		}
  49  	}
  50  	if p.cfg == nil || p.db == nil {
  51  		return errorf.E("both config and database must be set")
  52  	}
  53  
  54  	// Build owner/admin sets
  55  	newOwnerSet := make(map[string]struct{})
  56  	var newOwners [][]byte
  57  	for _, owner := range p.cfg.Owners {
  58  		if own, e := bech32encoding.NpubOrHexToPublicKeyBinary(owner); chk.E(e) {
  59  			continue
  60  		} else {
  61  			newOwners = append(newOwners, own)
  62  			newOwnerSet[hex.EncodeToString(own)] = struct{}{}
  63  		}
  64  	}
  65  
  66  	newAdminSet := make(map[string]struct{})
  67  	var newAdmins [][]byte
  68  	for _, admin := range p.cfg.Admins {
  69  		if adm, e := bech32encoding.NpubOrHexToPublicKeyBinary(admin); chk.E(e) {
  70  			continue
  71  		} else {
  72  			newAdmins = append(newAdmins, adm)
  73  			newAdminSet[hex.EncodeToString(adm)] = struct{}{}
  74  		}
  75  	}
  76  
  77  	// Load active subscriptions into memory
  78  	newActiveSet := make(map[string]time.Time)
  79  	subs, err := p.db.ListPaidSubscriptions()
  80  	if err != nil {
  81  		log.W.F("paid ACL: failed to load subscriptions: %v", err)
  82  		err = nil
  83  	}
  84  	now := time.Now()
  85  	for _, sub := range subs {
  86  		if sub.ExpiresAt.After(now) {
  87  			newActiveSet[sub.PubkeyHex] = sub.ExpiresAt
  88  		}
  89  	}
  90  
  91  	p.mx.Lock()
  92  	p.owners = newOwners
  93  	p.admins = newAdmins
  94  	p.ownerSet = newOwnerSet
  95  	p.adminSet = newAdminSet
  96  	p.activeSet = newActiveSet
  97  	p.mx.Unlock()
  98  
  99  	log.I.F("paid ACL configured: %d owners, %d admins, %d active subscribers",
 100  		len(newOwners), len(newAdmins), len(newActiveSet))
 101  
 102  	return nil
 103  }
 104  
 105  func (p *Paid) GetAccessLevel(pub []byte, address string) (level string) {
 106  	pubHex := hex.EncodeToString(pub)
 107  
 108  	p.mx.RLock()
 109  	defer p.mx.RUnlock()
 110  
 111  	if _, ok := p.ownerSet[pubHex]; ok {
 112  		return "owner"
 113  	}
 114  	if _, ok := p.adminSet[pubHex]; ok {
 115  		return "admin"
 116  	}
 117  	if expiry, ok := p.activeSet[pubHex]; ok {
 118  		if time.Now().Before(expiry) {
 119  			return "write"
 120  		}
 121  	}
 122  	return "read"
 123  }
 124  
 125  func (p *Paid) GetACLInfo() (name, description, documentation string) {
 126  	return "paid", "Lightning payment-gated access control",
 127  		"This ACL mode grants write access to subscribers who pay via Lightning. " +
 128  			"Users can also claim email aliases for a higher monthly rate."
 129  }
 130  
 131  func (p *Paid) Type() string { return "paid" }
 132  
 133  // Syncer runs a periodic expiry cleanup goroutine.
 134  func (p *Paid) Syncer() {
 135  	if p.Ctx == nil {
 136  		return
 137  	}
 138  	go p.expiryLoop()
 139  }
 140  
 141  func (p *Paid) expiryLoop() {
 142  	ticker := time.NewTicker(5 * time.Minute)
 143  	defer ticker.Stop()
 144  
 145  	for {
 146  		select {
 147  		case <-p.Ctx.Done():
 148  			return
 149  		case <-ticker.C:
 150  			p.cleanExpired()
 151  		}
 152  	}
 153  }
 154  
 155  func (p *Paid) cleanExpired() {
 156  	now := time.Now()
 157  	p.mx.Lock()
 158  	for pubkey, expiry := range p.activeSet {
 159  		if now.After(expiry) {
 160  			delete(p.activeSet, pubkey)
 161  		}
 162  	}
 163  	p.mx.Unlock()
 164  }
 165  
 166  // Subscribe activates a subscription for a pubkey.
 167  func (p *Paid) Subscribe(pubkeyHex string, expiresAt time.Time, invoiceHash, alias string) error {
 168  	sub := &database.PaidSubscription{
 169  		PubkeyHex:   pubkeyHex,
 170  		Alias:       alias,
 171  		ExpiresAt:   expiresAt,
 172  		CreatedAt:   time.Now(),
 173  		InvoiceHash: invoiceHash,
 174  	}
 175  	if err := p.db.SavePaidSubscription(sub); err != nil {
 176  		return err
 177  	}
 178  
 179  	p.mx.Lock()
 180  	p.activeSet[pubkeyHex] = expiresAt
 181  	p.mx.Unlock()
 182  
 183  	log.I.F("paid ACL: subscription activated for %s (expires %s)", pubkeyHex, expiresAt.Format(time.RFC3339))
 184  	return nil
 185  }
 186  
 187  // Unsubscribe removes a subscription.
 188  func (p *Paid) Unsubscribe(pubkeyHex string) error {
 189  	if err := p.db.DeletePaidSubscription(pubkeyHex); err != nil {
 190  		return err
 191  	}
 192  
 193  	p.mx.Lock()
 194  	delete(p.activeSet, pubkeyHex)
 195  	p.mx.Unlock()
 196  
 197  	return nil
 198  }
 199  
 200  // IsSubscribed returns true if the pubkey has an active (non-expired) subscription.
 201  func (p *Paid) IsSubscribed(pubkeyHex string) bool {
 202  	p.mx.RLock()
 203  	expiry, ok := p.activeSet[pubkeyHex]
 204  	p.mx.RUnlock()
 205  	return ok && time.Now().Before(expiry)
 206  }
 207  
 208  // GetSubscription returns the subscription for a pubkey.
 209  func (p *Paid) GetSubscription(pubkeyHex string) (*database.PaidSubscription, error) {
 210  	return p.db.GetPaidSubscription(pubkeyHex)
 211  }
 212  
 213  // ClaimAlias claims an alias for a pubkey. Validates the alias and delegates to DB.
 214  func (p *Paid) ClaimAlias(alias, pubkeyHex string) error {
 215  	if err := ValidateAlias(alias); err != nil {
 216  		return err
 217  	}
 218  	return p.db.ClaimAlias(alias, pubkeyHex)
 219  }
 220  
 221  // GetAliasByPubkey returns the alias for a pubkey, or "" if none.
 222  func (p *Paid) GetAliasByPubkey(pubkeyHex string) (string, error) {
 223  	return p.db.GetAliasByPubkey(pubkeyHex)
 224  }
 225  
 226  // GetPubkeyByAlias returns the pubkey for an alias, or "" if not found.
 227  func (p *Paid) GetPubkeyByAlias(alias string) (string, error) {
 228  	return p.db.GetPubkeyByAlias(alias)
 229  }
 230  
 231  // IsAliasTaken returns true if the alias is claimed.
 232  func (p *Paid) IsAliasTaken(alias string) (bool, error) {
 233  	return p.db.IsAliasTaken(alias)
 234  }
 235  
 236  // GetDatabase returns the underlying database for direct access.
 237  func (p *Paid) GetDatabase() database.Database {
 238  	return p.db
 239  }
 240  
 241  func init() {
 242  	Registry.Register(new(Paid))
 243  }
 244