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