Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}

Expand Down
7 changes: 6 additions & 1 deletion lib/handlers/nostr/kind0/kind0handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions lib/handlers/nostr/kind3/kind3handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions lib/types/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
74 changes: 72 additions & 2 deletions services/push/payload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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")
}
}
Loading