333 lines
8.2 KiB
Go
333 lines
8.2 KiB
Go
//go:build js && wasm
|
|
|
|
package wasmdb
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/aperturerobotics/go-indexeddb/idb"
|
|
|
|
"next.orly.dev/pkg/database"
|
|
)
|
|
|
|
const (
|
|
// SubscriptionsStoreName is the object store for payment subscriptions
|
|
SubscriptionsStoreName = "subscriptions"
|
|
|
|
// PaymentsPrefix is the key prefix for payment records
|
|
PaymentsPrefix = "payment:"
|
|
)
|
|
|
|
// GetSubscription retrieves a subscription for a pubkey
|
|
func (w *W) GetSubscription(pubkey []byte) (*database.Subscription, error) {
|
|
key := "sub:" + string(pubkey)
|
|
data, err := w.getStoreValue(SubscriptionsStoreName, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
return w.deserializeSubscription(data)
|
|
}
|
|
|
|
// IsSubscriptionActive checks if a pubkey has an active subscription
|
|
// If no subscription exists, creates a 30-day trial
|
|
func (w *W) IsSubscriptionActive(pubkey []byte) (bool, error) {
|
|
key := "sub:" + string(pubkey)
|
|
data, err := w.getStoreValue(SubscriptionsStoreName, key)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
if data == nil {
|
|
// Create new trial subscription
|
|
sub := &database.Subscription{
|
|
TrialEnd: now.AddDate(0, 0, 30),
|
|
}
|
|
subData := w.serializeSubscription(sub)
|
|
if err := w.setStoreValue(SubscriptionsStoreName, key, subData); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
sub, err := w.deserializeSubscription(data)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Active if within trial or paid period
|
|
return now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)), nil
|
|
}
|
|
|
|
// ExtendSubscription extends a subscription by the given number of days
|
|
func (w *W) ExtendSubscription(pubkey []byte, days int) error {
|
|
if days <= 0 {
|
|
return errors.New("invalid days")
|
|
}
|
|
|
|
key := "sub:" + string(pubkey)
|
|
data, err := w.getStoreValue(SubscriptionsStoreName, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
var sub *database.Subscription
|
|
|
|
if data == nil {
|
|
// Create new subscription
|
|
sub = &database.Subscription{
|
|
PaidUntil: now.AddDate(0, 0, days),
|
|
}
|
|
} else {
|
|
sub, err = w.deserializeSubscription(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Extend from current paid date if still active, otherwise from now
|
|
extendFrom := now
|
|
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
|
|
extendFrom = sub.PaidUntil
|
|
}
|
|
sub.PaidUntil = extendFrom.AddDate(0, 0, days)
|
|
}
|
|
|
|
// Serialize and store
|
|
subData := w.serializeSubscription(sub)
|
|
return w.setStoreValue(SubscriptionsStoreName, key, subData)
|
|
}
|
|
|
|
// RecordPayment records a payment for a pubkey
|
|
func (w *W) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
|
|
now := time.Now()
|
|
payment := &database.Payment{
|
|
Amount: amount,
|
|
Timestamp: now,
|
|
Invoice: invoice,
|
|
Preimage: preimage,
|
|
}
|
|
|
|
data := w.serializePayment(payment)
|
|
|
|
// Create unique key with timestamp
|
|
key := PaymentsPrefix + string(pubkey) + ":" + now.Format(time.RFC3339Nano)
|
|
return w.setStoreValue(SubscriptionsStoreName, key, data)
|
|
}
|
|
|
|
// GetPaymentHistory retrieves all payments for a pubkey
|
|
func (w *W) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) {
|
|
prefix := PaymentsPrefix + string(pubkey) + ":"
|
|
|
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, SubscriptionsStoreName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
store, err := tx.ObjectStore(SubscriptionsStoreName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payments []database.Payment
|
|
|
|
cursorReq, err := store.OpenCursor(idb.CursorNext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prefixBytes := []byte(prefix)
|
|
|
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error {
|
|
keyVal, keyErr := cursor.Key()
|
|
if keyErr != nil {
|
|
return keyErr
|
|
}
|
|
|
|
keyBytes := safeValueToBytes(keyVal)
|
|
if bytes.HasPrefix(keyBytes, prefixBytes) {
|
|
val, valErr := cursor.Value()
|
|
if valErr != nil {
|
|
return valErr
|
|
}
|
|
valBytes := safeValueToBytes(val)
|
|
if payment, err := w.deserializePayment(valBytes); err == nil {
|
|
payments = append(payments, *payment)
|
|
}
|
|
}
|
|
|
|
return cursor.Continue()
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return payments, nil
|
|
}
|
|
|
|
// ExtendBlossomSubscription extends a blossom subscription with storage quota
|
|
func (w *W) ExtendBlossomSubscription(pubkey []byte, level string, storageMB int64, days int) error {
|
|
if days <= 0 {
|
|
return errors.New("invalid days")
|
|
}
|
|
|
|
key := "sub:" + string(pubkey)
|
|
data, err := w.getStoreValue(SubscriptionsStoreName, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
var sub *database.Subscription
|
|
|
|
if data == nil {
|
|
sub = &database.Subscription{
|
|
PaidUntil: now.AddDate(0, 0, days),
|
|
BlossomLevel: level,
|
|
BlossomStorage: storageMB,
|
|
}
|
|
} else {
|
|
sub, err = w.deserializeSubscription(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Extend from current paid date if still active
|
|
extendFrom := now
|
|
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
|
|
extendFrom = sub.PaidUntil
|
|
}
|
|
sub.PaidUntil = extendFrom.AddDate(0, 0, days)
|
|
|
|
// Set level and accumulate storage
|
|
sub.BlossomLevel = level
|
|
if sub.BlossomStorage > 0 && sub.PaidUntil.After(now) {
|
|
sub.BlossomStorage += storageMB
|
|
} else {
|
|
sub.BlossomStorage = storageMB
|
|
}
|
|
}
|
|
|
|
subData := w.serializeSubscription(sub)
|
|
return w.setStoreValue(SubscriptionsStoreName, key, subData)
|
|
}
|
|
|
|
// GetBlossomStorageQuota returns the storage quota for a pubkey
|
|
func (w *W) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
|
|
sub, err := w.GetSubscription(pubkey)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if sub == nil {
|
|
return 0, nil
|
|
}
|
|
// Only return quota if subscription is active
|
|
if sub.PaidUntil.IsZero() || time.Now().After(sub.PaidUntil) {
|
|
return 0, nil
|
|
}
|
|
return sub.BlossomStorage, nil
|
|
}
|
|
|
|
// IsFirstTimeUser checks if a pubkey is a first-time user (no subscription history)
|
|
func (w *W) IsFirstTimeUser(pubkey []byte) (bool, error) {
|
|
key := "firstlogin:" + string(pubkey)
|
|
data, err := w.getStoreValue(SubscriptionsStoreName, key)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if data == nil {
|
|
// First time - record the login
|
|
now := time.Now()
|
|
loginData, _ := json.Marshal(map[string]interface{}{
|
|
"first_login": now,
|
|
})
|
|
_ = w.setStoreValue(SubscriptionsStoreName, key, loginData)
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// serializeSubscription converts a subscription to bytes using JSON
|
|
func (w *W) serializeSubscription(s *database.Subscription) []byte {
|
|
data, _ := json.Marshal(s)
|
|
return data
|
|
}
|
|
|
|
// deserializeSubscription converts bytes to a subscription
|
|
func (w *W) deserializeSubscription(data []byte) (*database.Subscription, error) {
|
|
s := &database.Subscription{}
|
|
if err := json.Unmarshal(data, s); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// serializePayment converts a payment to bytes
|
|
func (w *W) serializePayment(p *database.Payment) []byte {
|
|
buf := new(bytes.Buffer)
|
|
|
|
// Amount (8 bytes)
|
|
amt := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(amt, uint64(p.Amount))
|
|
buf.Write(amt)
|
|
|
|
// Timestamp (8 bytes)
|
|
ts := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(ts, uint64(p.Timestamp.Unix()))
|
|
buf.Write(ts)
|
|
|
|
// Invoice length (4 bytes) + Invoice
|
|
invBytes := []byte(p.Invoice)
|
|
invLen := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(invLen, uint32(len(invBytes)))
|
|
buf.Write(invLen)
|
|
buf.Write(invBytes)
|
|
|
|
// Preimage length (4 bytes) + Preimage
|
|
preBytes := []byte(p.Preimage)
|
|
preLen := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(preLen, uint32(len(preBytes)))
|
|
buf.Write(preLen)
|
|
buf.Write(preBytes)
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// deserializePayment converts bytes to a payment
|
|
func (w *W) deserializePayment(data []byte) (*database.Payment, error) {
|
|
if len(data) < 24 { // 8 + 8 + 4 + 4 minimum
|
|
return nil, errors.New("invalid payment data")
|
|
}
|
|
|
|
p := &database.Payment{}
|
|
|
|
p.Amount = int64(binary.BigEndian.Uint64(data[0:8]))
|
|
p.Timestamp = time.Unix(int64(binary.BigEndian.Uint64(data[8:16])), 0)
|
|
|
|
invLen := binary.BigEndian.Uint32(data[16:20])
|
|
if len(data) < int(20+invLen+4) {
|
|
return nil, errors.New("invalid invoice length")
|
|
}
|
|
p.Invoice = string(data[20 : 20+invLen])
|
|
|
|
offset := 20 + invLen
|
|
preLen := binary.BigEndian.Uint32(data[offset : offset+4])
|
|
if len(data) < int(offset+4+preLen) {
|
|
return nil, errors.New("invalid preimage length")
|
|
}
|
|
p.Preimage = string(data[offset+4 : offset+4+preLen])
|
|
|
|
return p, nil
|
|
}
|