diff --git a/config.example.yaml b/config.example.yaml index 14cc081..1c83658 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -238,6 +238,9 @@ push_notifications: project_id: "" service: batch_size: 100 + follow_cache_size: 500 + follow_cache_ttl: 5m + follow_gated: false queue_size: 1000 retry_attempts: 3 retry_delay: 5s diff --git a/lib/config/config.go b/lib/config/config.go index 04cd3ad..368583e 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -827,6 +827,9 @@ func setDefaults() { viper.SetDefault("push_notifications.service.batch_size", 100) viper.SetDefault("push_notifications.service.retry_attempts", 3) viper.SetDefault("push_notifications.service.retry_delay", "5s") + viper.SetDefault("push_notifications.service.follow_gated", false) + viper.SetDefault("push_notifications.service.follow_cache_size", 500) + viper.SetDefault("push_notifications.service.follow_cache_ttl", "5m") } // GetAllSettingsAsMap returns all configuration settings as a map @@ -961,6 +964,9 @@ func GetAllSettingsAsMap() (map[string]interface{}, error) { "batch_size": cfg.PushNotifications.Service.BatchSize, "retry_attempts": cfg.PushNotifications.Service.RetryAttempts, "retry_delay": cfg.PushNotifications.Service.RetryDelay, + "follow_gated": cfg.PushNotifications.Service.FollowGated, + "follow_cache_size": cfg.PushNotifications.Service.FollowCacheSize, + "follow_cache_ttl": cfg.PushNotifications.Service.FollowCacheTTL, }, } diff --git a/lib/handlers/nostr/kind0/kind0handler.go b/lib/handlers/nostr/kind0/kind0handler.go index c5f44cc..0e6dde1 100644 --- a/lib/handlers/nostr/kind0/kind0handler.go +++ b/lib/handlers/nostr/kind0/kind0handler.go @@ -134,9 +134,14 @@ func BuildKind0Handler(store stores.Store, relayPrivKey *btcec.PrivateKey) func( return } - // Delete existing kind 0 events if any + // Delete existing kind 0 events if any (NIP-01 replaceable event semantics) if len(existingEvents) > 0 { for _, oldEvent := range existingEvents { + if oldEvent.CreatedAt > env.Event.CreatedAt { + // Existing event is newer โ€” reject the incoming event + write("OK", env.Event.ID, false, "blocked: existing profile is newer") + return + } if err := store.DeleteEvent(oldEvent.ID); err != nil { logging.Infof("Error deleting old kind 0 event %s: %v", oldEvent.ID, err) write("NOTICE", "Error deleting old kind 0 event %s: %v", oldEvent.ID, err) diff --git a/lib/handlers/nostr/kind3/kind3handler.go b/lib/handlers/nostr/kind3/kind3handler.go index d734f57..1f8cdac 100644 --- a/lib/handlers/nostr/kind3/kind3handler.go +++ b/lib/handlers/nostr/kind3/kind3handler.go @@ -47,12 +47,16 @@ func BuildKind3Handler(store stores.Store) func(read lib_nostr.KindReader, write return } - // If there's an existing event, delete it + // If there are existing events, check timestamps (NIP-01 replaceable event semantics) if len(existingEvents) > 0 { for _, oldEvent := range existingEvents { + if oldEvent.CreatedAt > env.Event.CreatedAt { + // Existing event is newer โ€” reject the incoming event + write("OK", env.Event.ID, false, "blocked: existing contact list is newer") + return + } if err := store.DeleteEvent(oldEvent.ID); err != nil { logging.Infof("Error deleting old contact list event %s: %v", oldEvent.ID, err) - // Decide how to handle delete failures } } } diff --git a/lib/types/push.go b/lib/types/push.go index 08b0a65..8a1dbe6 100644 --- a/lib/types/push.go +++ b/lib/types/push.go @@ -61,4 +61,7 @@ type PushServiceConfig struct { BatchSize int `mapstructure:"batch_size"` RetryAttempts int `mapstructure:"retry_attempts"` RetryDelay string `mapstructure:"retry_delay"` + FollowGated bool `mapstructure:"follow_gated"` + FollowCacheSize int `mapstructure:"follow_cache_size"` + FollowCacheTTL string `mapstructure:"follow_cache_ttl"` } diff --git a/services/push/payload_test.go b/services/push/payload_test.go index 1d7f88c..720df95 100644 --- a/services/push/payload_test.go +++ b/services/push/payload_test.go @@ -59,11 +59,16 @@ func (m *mockPushStore) GetStatsStore() statistics.StatisticsStore { return nil } +// newTestPushService creates a PushService with a mock store for testing // newTestPushService creates a PushService with a mock store for testing func newTestPushService(events []*nostr.Event) *PushService { return &PushService{ - store: &mockPushStore{events: events}, - nameCache: make(map[string]string), + store: &mockPushStore{events: events}, + nameCache: make(map[string]string), + followCache: make(map[string]*followCacheEntry), + followCacheTTL: 5 * time.Minute, + followCacheMax: 500, + followGated: false, // disabled by default in tests } } @@ -421,4 +426,69 @@ func logPayload(t *testing.T, label string, message *PushMessage) { payload := message.ToAPNsPayload() payloadJSON, _ := json.MarshalIndent(payload, "", " ") t.Logf("\n๐Ÿ“ฑ %s APNs Payload:\n%s", label, string(payloadJSON)) +} + +// TestFollowGate_BlocksNonFollower verifies that notifications are blocked +// when the recipient does not follow the event author. +func TestFollowGate_BlocksNonFollower(t *testing.T) { + // Recipient's contact list (kind 3) โ€” follows only "friend_pubkey" + contactList := &nostr.Event{ + ID: "contact_list_123", + PubKey: "recipient_pubkey", + Kind: 3, + Tags: nostr.Tags{{"p", "friend_pubkey"}}, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + } + + ps := newTestPushService([]*nostr.Event{contactList}) + ps.followGated = true + + // Event from someone the recipient follows โ€” should be allowed + friendEvent := &nostr.Event{Kind: 1, PubKey: "friend_pubkey"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "friend_pubkey", friendEvent) { + t.Error("Expected notification to be allowed for followed author") + } + + // Event from someone the recipient does NOT follow โ€” should be blocked + strangerEvent := &nostr.Event{Kind: 1, PubKey: "stranger_pubkey"} + if ps.recipientFollowsAuthor("recipient_pubkey", "stranger_pubkey", strangerEvent) { + t.Error("Expected notification to be blocked for non-followed author") + } +} + +// TestFollowGate_AllowsKind1059 verifies that encrypted DMs (Gift Wrap) +// bypass the follow gate since the pubkey is ephemeral. +func TestFollowGate_AllowsKind1059(t *testing.T) { + ps := newTestPushService(nil) + ps.followGated = true + + giftWrap := &nostr.Event{Kind: 1059, PubKey: "ephemeral_key"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "ephemeral_key", giftWrap) { + t.Error("Expected kind 1059 to bypass follow gate") + } +} + +// TestFollowGate_AllowsTestNotification verifies that test notifications +// (all-zeros pubkey) bypass the follow gate. +func TestFollowGate_AllowsTestNotification(t *testing.T) { + ps := newTestPushService(nil) + ps.followGated = true + + testEvent := &nostr.Event{Kind: 1808, PubKey: "0000000000000000000000000000000000000000000000000000000000000000"} + if !ps.recipientFollowsAuthor("recipient_pubkey", testEvent.PubKey, testEvent) { + t.Error("Expected test notification to bypass follow gate") + } +} + +// TestFollowGate_AllowsNoContactList verifies that users with no contact list +// still receive all notifications (permissive for new users). +func TestFollowGate_AllowsNoContactList(t *testing.T) { + // No events in store โ€” recipient has no kind 3 + ps := newTestPushService(nil) + ps.followGated = true + + event := &nostr.Event{Kind: 1, PubKey: "some_author"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "some_author", event) { + t.Error("Expected notification to be allowed when recipient has no contact list") + } } \ No newline at end of file diff --git a/services/push/service.go b/services/push/service.go index b29afb5..e18d0e8 100644 --- a/services/push/service.go +++ b/services/push/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/HORNET-Storage/hornet-storage/lib/config" "github.com/HORNET-Storage/hornet-storage/lib/logging" @@ -13,23 +14,38 @@ import ( "github.com/nbd-wtf/go-nostr" ) +const ( + defaultFollowCacheSize = 500 + defaultFollowCacheTTL = 5 * time.Minute +) + +// followCacheEntry stores a user's follow set with expiry +type followCacheEntry struct { + follows map[string]bool // set of pubkeys this user follows; nil = no contact list found + cachedAt time.Time +} // PushService manages push notifications type PushService struct { - store stores.Store - config *types.PushNotificationConfig - queue chan *NotificationTask - workers []*Worker - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - mutex sync.RWMutex - apnsClient APNSClient - fcmClient FCMClient - isRunning bool - nameCache map[string]string // Cache for author names (pubkey -> name) - cacheMutex sync.RWMutex // Mutex for cache access - processedIDs map[string]bool // Track processed event IDs to prevent duplicates - idMutex sync.RWMutex // Mutex for processed IDs + store stores.Store + config *types.PushNotificationConfig + queue chan *NotificationTask + workers []*Worker + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mutex sync.RWMutex + apnsClient APNSClient + fcmClient FCMClient + isRunning bool + nameCache map[string]string // Cache for author names (pubkey -> name) + cacheMutex sync.RWMutex // Mutex for cache access + processedIDs map[string]bool // Track processed event IDs to prevent duplicates + idMutex sync.RWMutex // Mutex for processed IDs + followCache map[string]*followCacheEntry // recipientPubkey -> follow set + followMutex sync.RWMutex // Mutex for follow cache + followCacheTTL time.Duration // TTL for follow cache entries + followCacheMax int // Max entries in follow cache + followGated bool // Whether follow-gating is enabled } // NotificationTask represents a push notification task @@ -85,14 +101,30 @@ func NewPushService(store stores.Store) (*PushService, error) { ctx, cancel := context.WithCancel(context.Background()) + // Parse follow cache TTL + followTTL := defaultFollowCacheTTL + if cfg.PushNotifications.Service.FollowCacheTTL != "" { + if parsed, err := time.ParseDuration(cfg.PushNotifications.Service.FollowCacheTTL); err == nil { + followTTL = parsed + } + } + followMax := cfg.PushNotifications.Service.FollowCacheSize + if followMax <= 0 { + followMax = defaultFollowCacheSize + } + service := &PushService{ - store: store, - config: &cfg.PushNotifications, - queue: make(chan *NotificationTask, cfg.PushNotifications.Service.QueueSize), - ctx: ctx, - cancel: cancel, - nameCache: make(map[string]string), - processedIDs: make(map[string]bool), + store: store, + config: &cfg.PushNotifications, + queue: make(chan *NotificationTask, cfg.PushNotifications.Service.QueueSize), + ctx: ctx, + cancel: cancel, + nameCache: make(map[string]string), + processedIDs: make(map[string]bool), + followCache: make(map[string]*followCacheEntry), + followCacheTTL: followTTL, + followCacheMax: followMax, + followGated: cfg.PushNotifications.Service.FollowGated, } // Initialize APNs client if enabled @@ -198,6 +230,12 @@ func (ps *PushService) ProcessEvent(event *nostr.Event) { logging.Infof("๐Ÿ”” Processing event for push notifications - Kind: %d, Event ID: %s, Author: %s", event.Kind, event.ID, event.PubKey) + // Proactively warm follow cache when kind 3 events arrive + // (kind 3 is disabled for push notifications, but we still use it to update the follow cache) + if event.Kind == 3 && ps.followGated { + ps.warmFollowCacheFromEvent(event) + } + // Check if this event type should trigger notifications if !ps.shouldNotify(event) { logging.Debugf("Event kind %d does not trigger notifications", event.Kind) @@ -214,6 +252,13 @@ func (ps *PushService) ProcessEvent(event *nostr.Event) { continue } + // Follow-gate: skip notification if recipient doesn't follow the author + if ps.followGated && !ps.recipientFollowsAuthor(pubkey, event.PubKey, event) { + logging.Debugf("โญ๏ธ Skipping notification: %s does not follow author %s", + shortenPubkey(pubkey), shortenPubkey(event.PubKey)) + continue + } + // Get devices for this user // Need to get StatsStore first statsStore := ps.store.GetStatsStore() @@ -694,6 +739,145 @@ func (ps *PushService) formatNotificationMessage(event *nostr.Event, recipient s return message } +// recipientFollowsAuthor checks if the recipient follows the event author. +// Returns true (allow notification) if: +// - The event kind is exempt from follow-gating (kind 1059, test notifications) +// - The recipient has no contact list (permissive for new users) +// - The recipient's contact list contains the author +func (ps *PushService) recipientFollowsAuthor(recipientPubkey, authorPubkey string, event *nostr.Event) bool { + // Exempt: test notifications (all-zeros pubkey used by test handler) + if authorPubkey == "0000000000000000000000000000000000000000000000000000000000000000" { + return true + } + // Exempt: Gift Wrap DMs (kind 1059) โ€” pubkey is ephemeral, no one "follows" it + if event.Kind == 1059 { + return true + } + + // Check cache + ps.followMutex.RLock() + if entry, ok := ps.followCache[recipientPubkey]; ok { + if time.Since(entry.cachedAt) < ps.followCacheTTL { + ps.followMutex.RUnlock() + if entry.follows == nil { + return true // No contact list = permissive + } + return entry.follows[authorPubkey] + } + } + ps.followMutex.RUnlock() + + // Cache miss โ€” query store for recipient's kind 3 event + follows := ps.loadFollowSet(recipientPubkey) + + // Store in cache + ps.followMutex.Lock() + ps.followCache[recipientPubkey] = &followCacheEntry{ + follows: follows, + cachedAt: time.Now(), + } + if len(ps.followCache) > ps.followCacheMax { + ps.evictOldestFollowEntries() + } + ps.followMutex.Unlock() + + if follows == nil { + return true // No contact list = permissive (don't block new users) + } + return follows[authorPubkey] +} + +// loadFollowSet queries the store for a user's kind 3 contact list and +// returns a set of followed pubkeys. Returns nil if no contact list exists. +func (ps *PushService) loadFollowSet(pubkey string) map[string]bool { + filter := nostr.Filter{ + Authors: []string{pubkey}, + Kinds: []int{3}, + Limit: 1, + } + events, err := ps.store.QueryEvents(filter) + if err != nil || len(events) == 0 { + return nil // nil = no contact list found + } + + follows := make(map[string]bool) + for _, tag := range events[0].Tags { + if len(tag) >= 2 && tag[0] == "p" { + follows[tag[1]] = true + } + } + return follows +} + +// warmFollowCacheFromEvent updates the follow cache directly from a kind 3 event. +// Called when ProcessEvent receives a kind 3 event (before shouldNotify filters it out). +// Checks the store first to avoid overwriting the cache with a stale contact list. +func (ps *PushService) warmFollowCacheFromEvent(event *nostr.Event) { + // Don't warm cache with stale data โ€” check if store already has a newer kind 3 + existingFilter := nostr.Filter{ + Authors: []string{event.PubKey}, + Kinds: []int{3}, + Limit: 1, + } + if existing, err := ps.store.QueryEvents(existingFilter); err == nil && len(existing) > 0 { + if existing[0].CreatedAt > event.CreatedAt { + logging.Debugf("๐Ÿ”„ Skipping cache warm for %s โ€” store has newer kind 3", shortenPubkey(event.PubKey)) + return + } + } + + follows := make(map[string]bool) + for _, tag := range event.Tags { + if len(tag) >= 2 && tag[0] == "p" { + follows[tag[1]] = true + } + } + + ps.followMutex.Lock() + ps.followCache[event.PubKey] = &followCacheEntry{ + follows: follows, + cachedAt: time.Now(), + } + ps.followMutex.Unlock() + + logging.Debugf("๐Ÿ”„ Warmed follow cache for %s (%d follows)", shortenPubkey(event.PubKey), len(follows)) +} + +// evictOldestFollowEntries removes the oldest 10% of follow cache entries. +// Must be called with followMutex held for writing. +func (ps *PushService) evictOldestFollowEntries() { + type keyAge struct { + key string + cachedAt time.Time + } + + itemsToRemove := ps.followCacheMax / 10 + if itemsToRemove < 1 { + itemsToRemove = 1 + } + + items := make([]keyAge, 0, len(ps.followCache)) + for k, v := range ps.followCache { + items = append(items, keyAge{k, v.cachedAt}) + } + + // Selection sort to find oldest N items to remove + for i := 0; i < itemsToRemove && i < len(items); i++ { + oldest := i + for j := i + 1; j < len(items); j++ { + if items[j].cachedAt.Before(items[oldest].cachedAt) { + oldest = j + } + } + if oldest != i { + items[i], items[oldest] = items[oldest], items[i] + } + delete(ps.followCache, items[i].key) + } + + logging.Debugf("๐Ÿงน Evicted %d oldest follow cache entries", itemsToRemove) +} + // Global service instance var globalPushService *PushService var serviceMutex sync.RWMutex