diff --git a/app/config/config.go b/app/config/config.go index a330b43..f8285ee 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -139,6 +139,21 @@ func GetEnv() (requested bool) { return } +// IdentityRequested checks if the first command line argument is "identity" and returns +// whether the relay identity should be printed and the program should exit. +// +// Return Values +// - requested: true if the 'identity' subcommand was provided, false otherwise. +func IdentityRequested() (requested bool) { + if len(os.Args) > 1 { + switch strings.ToLower(os.Args[1]) { + case "identity": + requested = true + } + } + return +} + // KV is a key/value pair. type KV struct{ Key, Value string } diff --git a/app/handle-auth.go b/app/handle-auth.go index 011bc41..ea27683 100644 --- a/app/handle-auth.go +++ b/app/handle-auth.go @@ -50,6 +50,30 @@ func (l *Listener) HandleAuth(b []byte) (err error) { env.Event.Pubkey, ) l.authedPubkey.Store(env.Event.Pubkey) + + // Check if this is a first-time user and create welcome note + go l.handleFirstTimeUser(env.Event.Pubkey) } return } + +// handleFirstTimeUser checks if user is logging in for first time and creates welcome note +func (l *Listener) handleFirstTimeUser(pubkey []byte) { + // Check if this is a first-time user + isFirstTime, err := l.Server.D.IsFirstTimeUser(pubkey) + if err != nil { + log.E.F("failed to check first-time user status: %v", err) + return + } + + if !isFirstTime { + return // Not a first-time user + } + + // Get payment processor to create welcome note + if l.Server.paymentProcessor != nil { + if err := l.Server.paymentProcessor.CreateWelcomeNote(pubkey); err != nil { + log.E.F("failed to create welcome note for first-time user: %v", err) + } + } +} diff --git a/app/handle-req.go b/app/handle-req.go index 3c86065..4e4b82c 100644 --- a/app/handle-req.go +++ b/app/handle-req.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/dgraph-io/badger/v4" "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/pkg/acl" + "next.orly.dev/pkg/encoders/bech32encoding" "next.orly.dev/pkg/encoders/envelopes/authenvelope" "next.orly.dev/pkg/encoders/envelopes/closedenvelope" "next.orly.dev/pkg/encoders/envelopes/eoseenvelope" @@ -143,6 +145,43 @@ func (l *Listener) HandleReq(msg []byte) (err error) { var tmp event.S privCheck: for _, ev := range events { + // Check for private tag first + privateTags := ev.Tags.GetAll([]byte("private")) + if len(privateTags) > 0 && accessLevel != "admin" { + pk := l.authedPubkey.Load() + if pk == nil { + continue // no auth, can't access private events + } + + // Convert authenticated pubkey to npub for comparison + authedNpub, err := bech32encoding.BinToNpub(pk) + if err != nil { + continue // couldn't convert pubkey, skip + } + + // Check if authenticated npub is in any private tag + authorized := false + for _, privateTag := range privateTags { + authorizedNpubs := strings.Split(string(privateTag.Value()), ",") + for _, npub := range authorizedNpubs { + if strings.TrimSpace(npub) == string(authedNpub) { + authorized = true + break + } + } + if authorized { + break + } + } + + if !authorized { + continue // not authorized to see this private event + } + + tmp = append(tmp, ev) + continue + } + if kind.IsPrivileged(ev.Kind) && accessLevel != "admin" { // admins can see all events // log.T.C( diff --git a/app/main.go b/app/main.go index e28b088..2c08e3e 100644 --- a/app/main.go +++ b/app/main.go @@ -8,6 +8,7 @@ import ( "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/app/config" + "next.orly.dev/pkg/crypto/keys" "next.orly.dev/pkg/database" "next.orly.dev/pkg/encoders/bech32encoding" "next.orly.dev/pkg/protocol/publish" @@ -47,6 +48,28 @@ func Run( } // Initialize the user interface l.UserInterface() + + // Ensure a relay identity secret key exists when subscriptions and NWC are enabled + if cfg.SubscriptionEnabled && cfg.NWCUri != "" { + if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil { + log.E.F("failed to ensure relay identity key: %v", e) + } else if pk, e2 := keys.SecretBytesToPubKeyHex(skb); e2 == nil { + log.I.F("relay identity loaded (pub=%s)", pk) + // ensure relay identity pubkey is considered an admin for ACL follows mode + found := false + for _, a := range cfg.Admins { + if a == pk { + found = true + break + } + } + if !found { + cfg.Admins = append(cfg.Admins, pk) + log.I.F("added relay identity to admins for follow-list whitelisting") + } + } + } + if l.paymentProcessor, err = NewPaymentProcessor(ctx, cfg, db); err != nil { log.E.F("failed to create payment processor: %v", err) // Continue without payment processor diff --git a/app/payment_processor.go b/app/payment_processor.go index 6ed380b..419deb0 100644 --- a/app/payment_processor.go +++ b/app/payment_processor.go @@ -2,15 +2,26 @@ package app import ( "context" + // std hex not used; use project hex encoder instead "fmt" "strings" "sync" + "time" + "github.com/dgraph-io/badger/v4" "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/app/config" + "next.orly.dev/pkg/acl" + "next.orly.dev/pkg/crypto/p256k" "next.orly.dev/pkg/database" "next.orly.dev/pkg/encoders/bech32encoding" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/json" + "next.orly.dev/pkg/encoders/kind" + "next.orly.dev/pkg/encoders/tag" + "next.orly.dev/pkg/encoders/timestamp" "next.orly.dev/pkg/protocol/nwc" ) @@ -52,6 +63,7 @@ func NewPaymentProcessor( // Start begins listening for payment notifications func (pp *PaymentProcessor) Start() error { + // start NWC notifications listener pp.wg.Add(1) go func() { defer pp.wg.Done() @@ -59,6 +71,20 @@ func (pp *PaymentProcessor) Start() error { log.E.F("payment processor error: %v", err) } }() + // start periodic follow-list sync if subscriptions are enabled + if pp.config != nil && pp.config.SubscriptionEnabled { + pp.wg.Add(1) + go func() { + defer pp.wg.Done() + pp.runFollowSyncLoop() + }() + // start daily subscription checker + pp.wg.Add(1) + go func() { + defer pp.wg.Done() + pp.runDailySubscriptionChecker() + }() + } return nil } @@ -75,6 +101,359 @@ func (pp *PaymentProcessor) listenForPayments() error { return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification) } +// runFollowSyncLoop periodically syncs the relay identity follow list with active subscribers +func (pp *PaymentProcessor) runFollowSyncLoop() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + // do an initial sync shortly after start + _ = pp.syncFollowList() + for { + select { + case <-pp.ctx.Done(): + return + case <-t.C: + if err := pp.syncFollowList(); err != nil { + log.W.F("follow list sync failed: %v", err) + } + } + } +} + +// runDailySubscriptionChecker checks once daily for subscription expiry warnings and trial reminders +func (pp *PaymentProcessor) runDailySubscriptionChecker() { + t := time.NewTicker(24 * time.Hour) + defer t.Stop() + // do an initial check shortly after start + _ = pp.checkSubscriptionStatus() + for { + select { + case <-pp.ctx.Done(): + return + case <-t.C: + if err := pp.checkSubscriptionStatus(); err != nil { + log.W.F("subscription status check failed: %v", err) + } + } + } +} + +// syncFollowList builds a kind-3 event from the relay identity containing only active subscribers +func (pp *PaymentProcessor) syncFollowList() error { + // ensure we have a relay identity secret + skb, err := pp.db.GetRelayIdentitySecret() + if err != nil || len(skb) != 32 { + return nil // nothing to do if no identity + } + // collect active subscribers + actives, err := pp.getActiveSubscriberPubkeys() + if err != nil { + return err + } + // signer + sign := new(p256k.Signer) + if err := sign.InitSec(skb); err != nil { + return err + } + // build follow list event + ev := event.New() + ev.Kind = kind.FollowList.K + ev.Pubkey = sign.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Tags = tag.NewS() + for _, pk := range actives { + *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(pk))) + } + // sign and save + ev.Sign(sign) + if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { + return err + } + log.I.F( + "updated relay follow list with %d active subscribers", len(actives), + ) + return nil +} + +// getActiveSubscriberPubkeys scans the subscription records and returns active ones +func (pp *PaymentProcessor) getActiveSubscriberPubkeys() ([][]byte, error) { + prefix := []byte("sub:") + now := time.Now() + var out [][]byte + err := pp.db.DB.View( + func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + item := it.Item() + key := item.KeyCopy(nil) + // key format: sub: + hexpub := string(key[len(prefix):]) + var sub database.Subscription + if err := item.Value( + func(val []byte) error { + return json.Unmarshal(val, &sub) + }, + ); err != nil { + return err + } + if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) { + if b, err := hex.Dec(hexpub); err == nil { + out = append(out, b) + } + } + } + return nil + }, + ) + return out, err +} + +// checkSubscriptionStatus scans all subscriptions and creates warning/reminder notes +func (pp *PaymentProcessor) checkSubscriptionStatus() error { + prefix := []byte("sub:") + now := time.Now() + sevenDaysFromNow := now.AddDate(0, 0, 7) + + return pp.db.DB.View( + func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + item := it.Item() + key := item.KeyCopy(nil) + // key format: sub: + hexpub := string(key[len(prefix):]) + + var sub database.Subscription + if err := item.Value( + func(val []byte) error { + return json.Unmarshal(val, &sub) + }, + ); err != nil { + continue // skip invalid subscription records + } + + pubkey, err := hex.Dec(hexpub) + if err != nil { + continue // skip invalid pubkey + } + + // Check if paid subscription is expiring in 7 days + if !sub.PaidUntil.IsZero() { + // Format dates for comparison (ignore time component) + paidUntilDate := sub.PaidUntil.Truncate(24 * time.Hour) + sevenDaysDate := sevenDaysFromNow.Truncate(24 * time.Hour) + + if paidUntilDate.Equal(sevenDaysDate) { + go pp.createExpiryWarningNote(pubkey, sub.PaidUntil) + } + } + + // Check if user is on trial (no paid subscription, trial not expired) + if sub.PaidUntil.IsZero() && now.Before(sub.TrialEnd) { + go pp.createTrialReminderNote(pubkey, sub.TrialEnd) + } + } + return nil + }, + ) +} + +// createExpiryWarningNote creates a warning note for users whose paid subscription expires in 7 days +func (pp *PaymentProcessor) createExpiryWarningNote(userPubkey []byte, expiryTime time.Time) error { + // Get relay identity secret to sign the note + skb, err := pp.db.GetRelayIdentitySecret() + if err != nil || len(skb) != 32 { + return fmt.Errorf("no relay identity configured") + } + + // Initialize signer + sign := new(p256k.Signer) + if err := sign.InitSec(skb); err != nil { + return fmt.Errorf("failed to initialize signer: %w", err) + } + + monthlyPrice := pp.config.MonthlyPriceSats + if monthlyPrice <= 0 { + monthlyPrice = 6000 + } + + // Get relay npub for content link + relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) + if err != nil { + return fmt.Errorf("failed to encode relay npub: %w", err) + } + + // Create the warning note content + content := fmt.Sprintf(`⚠️ Subscription Expiring Soon ⚠️ + +Your paid subscription to this relay will expire in 7 days on %s. + +💰 To extend your subscription: +- Monthly price: %d sats +- Zap this note with your payment amount +- Each %d sats = 30 days of access + +⚡ Payment Instructions: +1. Use any Lightning wallet that supports zaps +2. Zap this note with your payment +3. Your subscription will be automatically extended + +Don't lose access to your private relay! Extend your subscription today. + +Relay: nostr:%s`, + expiryTime.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, monthlyPrice, string(relayNpubForContent)) + + // Build the event + ev := event.New() + ev.Kind = kind.TextNote.K // Kind 1 for text note + ev.Pubkey = sign.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Content = []byte(content) + ev.Tags = tag.NewS() + + // Add "p" tag for the user + *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey))) + + // Add expiration tag (5 days from creation) + noteExpiry := time.Now().AddDate(0, 0, 5) + *ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix()))) + + // Add "private" tag with authorized npubs (user and relay) + var authorizedNpubs []string + + // Add user npub + userNpub, err := bech32encoding.BinToNpub(userPubkey) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(userNpub)) + } + + // Add relay npub + relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(relayNpub)) + } + + // Create the private tag with comma-separated npubs + if len(authorizedNpubs) > 0 { + privateTagValue := strings.Join(authorizedNpubs, ",") + *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) + } + + // Add a special tag to mark this as an expiry warning + *ev.Tags = append(*ev.Tags, tag.NewFromAny("warning", "subscription-expiry")) + + // Sign and save the event + ev.Sign(sign) + if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { + return fmt.Errorf("failed to save expiry warning note: %w", err) + } + + log.I.F("created expiry warning note for user %s (expires %s)", hex.Enc(userPubkey), expiryTime.Format("2006-01-02")) + return nil +} + +// createTrialReminderNote creates a reminder note for users on trial to support the relay +func (pp *PaymentProcessor) createTrialReminderNote(userPubkey []byte, trialEnd time.Time) error { + // Get relay identity secret to sign the note + skb, err := pp.db.GetRelayIdentitySecret() + if err != nil || len(skb) != 32 { + return fmt.Errorf("no relay identity configured") + } + + // Initialize signer + sign := new(p256k.Signer) + if err := sign.InitSec(skb); err != nil { + return fmt.Errorf("failed to initialize signer: %w", err) + } + + monthlyPrice := pp.config.MonthlyPriceSats + if monthlyPrice <= 0 { + monthlyPrice = 6000 + } + + // Calculate daily rate + dailyRate := monthlyPrice / 30 + + // Get relay npub for content link + relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) + if err != nil { + return fmt.Errorf("failed to encode relay npub: %w", err) + } + + // Create the reminder note content + content := fmt.Sprintf(`🆓 Free Trial Reminder 🆓 + +You're currently using this relay for FREE! Your trial expires on %s. + +🙏 Support Relay Operations: +This relay provides you with private, censorship-resistant communication. Please consider supporting its continued operation. + +💰 Subscription Details: +- Monthly price: %d sats (%d sats/day) +- Fair pricing for premium service +- Helps keep the relay running 24/7 + +⚡ How to Subscribe: +Simply zap this note with your payment amount: +- Each %d sats = 30 days of access +- Payment is processed automatically +- No account setup required + +Thank you for considering supporting decentralized communication! + +Relay: nostr:%s`, + trialEnd.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, dailyRate, monthlyPrice, string(relayNpubForContent)) + + // Build the event + ev := event.New() + ev.Kind = kind.TextNote.K // Kind 1 for text note + ev.Pubkey = sign.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Content = []byte(content) + ev.Tags = tag.NewS() + + // Add "p" tag for the user + *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey))) + + // Add expiration tag (5 days from creation) + noteExpiry := time.Now().AddDate(0, 0, 5) + *ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix()))) + + // Add "private" tag with authorized npubs (user and relay) + var authorizedNpubs []string + + // Add user npub + userNpub, err := bech32encoding.BinToNpub(userPubkey) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(userNpub)) + } + + // Add relay npub + relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(relayNpub)) + } + + // Create the private tag with comma-separated npubs + if len(authorizedNpubs) > 0 { + privateTagValue := strings.Join(authorizedNpubs, ",") + *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) + } + + // Add a special tag to mark this as a trial reminder + *ev.Tags = append(*ev.Tags, tag.NewFromAny("reminder", "trial-support")) + + // Sign and save the event + ev.Sign(sign) + if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { + return fmt.Errorf("failed to save trial reminder note: %w", err) + } + + log.I.F("created trial reminder note for user %s (trial ends %s)", hex.Enc(userPubkey), trialEnd.Format("2006-01-02")) + return nil +} + // handleNotification processes incoming payment notifications func (pp *PaymentProcessor) handleNotification( notificationType string, notification map[string]any, @@ -89,22 +468,61 @@ func (pp *PaymentProcessor) handleNotification( return fmt.Errorf("invalid amount") } - description, _ := notification["description"].(string) - userNpub := pp.extractNpubFromDescription(description) - if userNpub == "" { - if metadata, ok := notification["metadata"].(map[string]any); ok { + // Prefer explicit payer/relay pubkeys if provided in metadata + var payerPubkey []byte + var userNpub string + if metadata, ok := notification["metadata"].(map[string]any); ok { + if s, ok := metadata["payer_pubkey"].(string); ok && s != "" { + if pk, err := decodeAnyPubkey(s); err == nil { + payerPubkey = pk + } + } + if payerPubkey == nil { + if s, ok := metadata["sender_pubkey"].(string); ok && s != "" { // alias + if pk, err := decodeAnyPubkey(s); err == nil { + payerPubkey = pk + } + } + } + // Optional: the intended subscriber npub (for backwards compat) + if userNpub == "" { if npubField, ok := metadata["npub"].(string); ok { userNpub = npubField } } + // If relay identity pubkey is provided, verify it matches ours + if s, ok := metadata["relay_pubkey"].(string); ok && s != "" { + if rpk, err := decodeAnyPubkey(s); err == nil { + if skb, err := pp.db.GetRelayIdentitySecret(); err == nil && len(skb) == 32 { + var signer p256k.Signer + if err := signer.InitSec(skb); err == nil { + if !strings.EqualFold(hex.Enc(rpk), hex.Enc(signer.Pub())) { + log.W.F("relay_pubkey in payment metadata does not match this relay identity: got %s want %s", hex.Enc(rpk), hex.Enc(signer.Pub())) + } + } + } + } + } } + + // Fallback: extract npub from description or metadata + description, _ := notification["description"].(string) if userNpub == "" { - return fmt.Errorf("no npub in payment description") + userNpub = pp.extractNpubFromDescription(description) } - pubkey, err := pp.npubToPubkey(userNpub) - if err != nil { - return fmt.Errorf("invalid npub: %w", err) + var pubkey []byte + var err error + if payerPubkey != nil { + pubkey = payerPubkey + } else { + if userNpub == "" { + return fmt.Errorf("no payer_pubkey or npub provided in payment notification") + } + pubkey, err = pp.npubToPubkey(userNpub) + if err != nil { + return fmt.Errorf("invalid npub: %w", err) + } } satsReceived := int64(amount / 1000) @@ -131,16 +549,215 @@ func (pp *PaymentProcessor) handleNotification( log.E.F("failed to record payment: %v", err) } - log.I.F( - "payment processed: %s %d sats -> %d days", userNpub, satsReceived, - days, - ) + // Log helpful identifiers + var payerHex = hex.Enc(pubkey) + if userNpub == "" { + log.I.F("payment processed: payer %s %d sats -> %d days", payerHex, satsReceived, days) + } else { + log.I.F("payment processed: %s (%s) %d sats -> %d days", userNpub, payerHex, satsReceived, days) + } + + // Update ACL follows cache and relay follow list immediately + if pp.config != nil && pp.config.ACLMode == "follows" { + acl.Registry.AddFollow(pubkey) + } + // Trigger an immediate follow-list sync in background (best-effort) + go func() { _ = pp.syncFollowList() }() + + // Create a note with payment confirmation and private tag + if err := pp.createPaymentNote(pubkey, satsReceived, days); err != nil { + log.E.F("failed to create payment note: %v", err) + } + + return nil +} + +// createPaymentNote creates a note recording the payment with private tag for authorization +func (pp *PaymentProcessor) createPaymentNote(payerPubkey []byte, satsReceived int64, days int) error { + // Get relay identity secret to sign the note + skb, err := pp.db.GetRelayIdentitySecret() + if err != nil || len(skb) != 32 { + return fmt.Errorf("no relay identity configured") + } + + // Initialize signer + sign := new(p256k.Signer) + if err := sign.InitSec(skb); err != nil { + return fmt.Errorf("failed to initialize signer: %w", err) + } + + // Get subscription info to determine expiry + sub, err := pp.db.GetSubscription(payerPubkey) + if err != nil { + return fmt.Errorf("failed to get subscription: %w", err) + } + + var expiryTime time.Time + if sub != nil && !sub.PaidUntil.IsZero() { + expiryTime = sub.PaidUntil + } else { + expiryTime = time.Now().AddDate(0, 0, days) + } + + // Get relay npub for content link + relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) + if err != nil { + return fmt.Errorf("failed to encode relay npub: %w", err) + } + + // Create the note content with nostr:npub link + content := fmt.Sprintf("Payment received: %d sats for %d days. Subscription expires: %s\n\nRelay: nostr:%s", + satsReceived, days, expiryTime.Format("2006-01-02 15:04:05 UTC"), string(relayNpubForContent)) + + // Build the event + ev := event.New() + ev.Kind = kind.TextNote.K // Kind 1 for text note + ev.Pubkey = sign.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Content = []byte(content) + ev.Tags = tag.NewS() + + // Add "p" tag for the payer + *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(payerPubkey))) + + // Add expiration tag (5 days from creation) + noteExpiry := time.Now().AddDate(0, 0, 5) + *ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix()))) + + // Add "private" tag with authorized npubs (payer and relay) + var authorizedNpubs []string + + // Add payer npub + payerNpub, err := bech32encoding.BinToNpub(payerPubkey) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(payerNpub)) + } + + // Add relay npub + relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(relayNpub)) + } + + // Create the private tag with comma-separated npubs + if len(authorizedNpubs) > 0 { + privateTagValue := strings.Join(authorizedNpubs, ",") + *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) + } + + // Sign and save the event + ev.Sign(sign) + if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { + return fmt.Errorf("failed to save payment note: %w", err) + } + + log.I.F("created payment note for %s with private authorization", hex.Enc(payerPubkey)) + return nil +} + +// CreateWelcomeNote creates a welcome note for first-time users with private tag for authorization +func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error { + // Get relay identity secret to sign the note + skb, err := pp.db.GetRelayIdentitySecret() + if err != nil || len(skb) != 32 { + return fmt.Errorf("no relay identity configured") + } + + // Initialize signer + sign := new(p256k.Signer) + if err := sign.InitSec(skb); err != nil { + return fmt.Errorf("failed to initialize signer: %w", err) + } + + monthlyPrice := pp.config.MonthlyPriceSats + if monthlyPrice <= 0 { + monthlyPrice = 6000 + } + + // Get relay npub for content link + relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) + if err != nil { + return fmt.Errorf("failed to encode relay npub: %w", err) + } + + // Create the welcome note content with nostr:npub link + content := fmt.Sprintf(`Welcome to the relay! 🎉 + +You have a FREE 30-day trial that started when you first logged in. + +💰 Subscription Details: +- Monthly price: %d sats +- Trial period: 30 days from first login + +💡 How to Subscribe: +To extend your subscription after the trial ends, simply zap this note with the amount you want to pay. Each %d sats = 30 days of access. + +⚡ Payment Instructions: +1. Use any Lightning wallet that supports zaps +2. Zap this note with your payment +3. Your subscription will be automatically extended + +Relay: nostr:%s + +Enjoy your time on the relay!`, monthlyPrice, monthlyPrice, string(relayNpubForContent)) + + // Build the event + ev := event.New() + ev.Kind = kind.TextNote.K // Kind 1 for text note + ev.Pubkey = sign.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Content = []byte(content) + ev.Tags = tag.NewS() + + // Add "p" tag for the user + *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey))) + + // Add expiration tag (5 days from creation) + noteExpiry := time.Now().AddDate(0, 0, 5) + *ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix()))) + // Add "private" tag with authorized npubs (user and relay) + var authorizedNpubs []string + + // Add user npub + userNpub, err := bech32encoding.BinToNpub(userPubkey) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(userNpub)) + } + + // Add relay npub + relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) + if err == nil { + authorizedNpubs = append(authorizedNpubs, string(relayNpub)) + } + + // Create the private tag with comma-separated npubs + if len(authorizedNpubs) > 0 { + privateTagValue := strings.Join(authorizedNpubs, ",") + *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) + } + + // Add a special tag to mark this as a welcome note + *ev.Tags = append(*ev.Tags, tag.NewFromAny("welcome", "first-time-user")) + + // Sign and save the event + ev.Sign(sign) + if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { + return fmt.Errorf("failed to save welcome note: %w", err) + } + + log.I.F("created welcome note for first-time user %s", hex.Enc(userPubkey)) return nil } // extractNpubFromDescription extracts an npub from the payment description func (pp *PaymentProcessor) extractNpubFromDescription(description string) string { + // check if the entire description is just an npub + description = strings.TrimSpace(description) + if strings.HasPrefix(description, "npub1") && len(description) == 63 { + return description + } + // Look for npub1... pattern in the description parts := strings.Fields(description) for _, part := range parts { @@ -149,12 +766,6 @@ func (pp *PaymentProcessor) extractNpubFromDescription(description string) strin } } - // Also check if the entire description is just an npub - description = strings.TrimSpace(description) - if strings.HasPrefix(description, "npub1") && len(description) == 63 { - return description - } - return "" } @@ -182,3 +793,24 @@ func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) { return pubkey, nil } + +// decodeAnyPubkey decodes a public key from either hex string or npub format +func decodeAnyPubkey(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "npub1") { + prefix, value, err := bech32encoding.Decode([]byte(s)) + if err != nil { + return nil, fmt.Errorf("failed to decode npub: %w", err) + } + if !strings.EqualFold(string(prefix), "npub") { + return nil, fmt.Errorf("invalid prefix: %s", string(prefix)) + } + b, ok := value.([]byte) + if !ok { + return nil, fmt.Errorf("decoded value is not []byte") + } + return b, nil + } + // assume hex-encoded public key + return hex.Dec(s) +} diff --git a/main.go b/main.go index 0815793..d6f78ca 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,9 @@ import ( "next.orly.dev/app" "next.orly.dev/app/config" "next.orly.dev/pkg/acl" + "next.orly.dev/pkg/crypto/keys" "next.orly.dev/pkg/database" + "next.orly.dev/pkg/encoders/hex" "next.orly.dev/pkg/spider" "next.orly.dev/pkg/version" ) @@ -51,11 +53,32 @@ func main() { runtime.GOMAXPROCS(runtime.NumCPU() * 4) var err error var cfg *config.C - if cfg, err = config.New(); chk.T(err) { - } - log.I.F("starting %s %s", cfg.AppName, version.V) + if cfg, err = config.New(); chk.T(err) { + } + log.I.F("starting %s %s", cfg.AppName, version.V) + + // Handle 'identity' subcommand: print relay identity secret and pubkey and exit + if config.IdentityRequested() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var db *database.D + if db, err = database.New(ctx, cancel, cfg.DataDir, cfg.DBLogLevel); chk.E(err) { + os.Exit(1) + } + defer db.Close() + skb, err := db.GetOrCreateRelayIdentitySecret() + if chk.E(err) { + os.Exit(1) + } + pk, err := keys.SecretBytesToPubKeyHex(skb) + if chk.E(err) { + os.Exit(1) + } + fmt.Printf("identity secret: %s\nidentity pubkey: %s\n", hex.Enc(skb), pk) + os.Exit(0) + } - // If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled + // If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled if cfg.OpenPprofWeb && cfg.Pprof != "" && !cfg.PprofHTTP { log.I.F("enabling HTTP pprof server to support web viewer") cfg.PprofHTTP = true diff --git a/pkg/acl/acl.go b/pkg/acl/acl.go index 0082c28..1c33838 100644 --- a/pkg/acl/acl.go +++ b/pkg/acl/acl.go @@ -66,3 +66,15 @@ func (s *S) Type() (typ string) { } return } + +// AddFollow forwards a pubkey to the active ACL if it supports dynamic follows +func (s *S) AddFollow(pub []byte) { + for _, i := range s.ACL { + if i.Type() == s.Active.Load() { + if f, ok := i.(*Follows); ok { + f.AddFollow(pub) + } + break + } + } +} diff --git a/pkg/acl/follows.go b/pkg/acl/follows.go index 2774c4e..0d6abb4 100644 --- a/pkg/acl/follows.go +++ b/pkg/acl/follows.go @@ -1,6 +1,7 @@ package acl import ( + "bytes" "context" "reflect" "strings" @@ -370,6 +371,32 @@ func (f *Follows) GetFollowedPubkeys() [][]byte { return followedPubkeys } +// AddFollow appends a pubkey to the in-memory follows list if not already present +// and signals the syncer to refresh subscriptions. +func (f *Follows) AddFollow(pub []byte) { + if len(pub) == 0 { + return + } + f.followsMx.Lock() + defer f.followsMx.Unlock() + for _, p := range f.follows { + if bytes.Equal(p, pub) { + return + } + } + b := make([]byte, len(pub)) + copy(b, pub) + f.follows = append(f.follows, b) + // notify syncer if initialized + if f.updated != nil { + select { + case f.updated <- struct{}{}: + default: + // if channel is full or not yet listened to, ignore + } + } +} + func init() { log.T.F("registering follows ACL") Registry.Register(new(Follows)) diff --git a/pkg/database/identity.go b/pkg/database/identity.go new file mode 100644 index 0000000..cf10642 --- /dev/null +++ b/pkg/database/identity.go @@ -0,0 +1,81 @@ +package database + +import ( + "errors" + "fmt" + + "github.com/dgraph-io/badger/v4" + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "next.orly.dev/pkg/crypto/keys" + "next.orly.dev/pkg/encoders/hex" +) + +const relayIdentitySecretKey = "relay:identity:sk" + +// GetRelayIdentitySecret returns the relay identity secret key bytes if present. +// If the key is not found, returns (nil, badger.ErrKeyNotFound). +func (d *D) GetRelayIdentitySecret() (skb []byte, err error) { + err = d.DB.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(relayIdentitySecretKey)) + if errors.Is(err, badger.ErrKeyNotFound) { + return err + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + // value stored as hex string + b, err := hex.Dec(string(val)) + if err != nil { + return err + } + skb = make([]byte, len(b)) + copy(skb, b) + return nil + }) + }) + return +} + +// SetRelayIdentitySecret stores the relay identity secret key bytes (expects 32 bytes). +func (d *D) SetRelayIdentitySecret(skb []byte) (err error) { + if len(skb) != 32 { + return fmt.Errorf("invalid secret key length: %d", len(skb)) + } + val := []byte(hex.Enc(skb)) + return d.DB.Update(func(txn *badger.Txn) error { + return txn.Set([]byte(relayIdentitySecretKey), val) + }) +} + +// GetOrCreateRelayIdentitySecret retrieves the existing relay identity secret +// key or creates and stores a new one if none exists. +func (d *D) GetOrCreateRelayIdentitySecret() (skb []byte, err error) { + // Try get fast path + if skb, err = d.GetRelayIdentitySecret(); err == nil && len(skb) == 32 { + return skb, nil + } + if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { + return nil, err + } + + // Create new key and store atomically + var gen []byte + if gen, err = keys.GenerateSecretKey(); chk.E(err) { + return nil, err + } + if err = d.SetRelayIdentitySecret(gen); chk.E(err) { + return nil, err + } + log.I.F("generated new relay identity key (pub=%s)", mustPub(gen)) + return gen, nil +} + +func mustPub(skb []byte) string { + pk, err := keys.SecretBytesToPubKeyHex(skb) + if err != nil { + return "" + } + return pk +} diff --git a/pkg/database/subscriptions.go b/pkg/database/subscriptions.go index dcf9538..a1261b4 100644 --- a/pkg/database/subscriptions.go +++ b/pkg/database/subscriptions.go @@ -188,3 +188,30 @@ func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) { return payments, err } + +// IsFirstTimeUser checks if a user is logging in for the first time and marks them as seen +func (d *D) IsFirstTimeUser(pubkey []byte) (bool, error) { + key := fmt.Sprintf("firstlogin:%s", hex.EncodeToString(pubkey)) + + isFirstTime := false + err := d.DB.Update( + func(txn *badger.Txn) error { + _, err := txn.Get([]byte(key)) + if errors.Is(err, badger.ErrKeyNotFound) { + // First time - record the login + isFirstTime = true + now := time.Now() + data, err := json.Marshal(map[string]interface{}{ + "first_login": now, + }) + if err != nil { + return err + } + return txn.Set([]byte(key), data) + } + return err // Return any other error as-is + }, + ) + + return isFirstTime, err +} diff --git a/pkg/version/version b/pkg/version/version index 6425bba..4ea5caf 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.7.1 \ No newline at end of file +v0.8.0 \ No newline at end of file