diff --git a/README.md b/README.md index 9e56314..7a01b79 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ Configures blossom support. - `enabled` - whether blossom is enabled. +### `[push]` + +Configures NIP 9a push support. + +- `enabled` - whether push is enabled. + ### `[roles]` Defines roles that can be assigned to different users and attendant privileges. Each role is defined by a `[roles.{role_name}]` header and has the following options: @@ -99,6 +105,9 @@ methods = ["supportedmethods", "banpubkey", "allowpubkey"] [blossom] enabled = false +[push] +enabled = false + [roles.member] can_invite = true diff --git a/cmd/import/main.go b/cmd/import/main.go index 41a6ac6..efb3245 100644 --- a/cmd/import/main.go +++ b/cmd/import/main.go @@ -28,8 +28,8 @@ func main() { var ( relay = flag.String("relay", "", "Relay name (required)") - reset = flag.Bool("reset", false, "Delete all events from the store before importing") - force = flag.Bool("force", false, "Skip validation prompts and import valid events only") + reset = flag.Bool("reset", false, "Delete all events from the store before importing") + force = flag.Bool("force", false, "Skip validation prompts and import valid events only") ) flag.Parse() diff --git a/go.mod b/go.mod index 1f7c42b..16c7952 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module zooid -go 1.24.1 +go 1.25 require ( fiatjaf.com/nostr v0.0.0-20251104112613-38a6ca92b954 @@ -35,6 +35,8 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/rs/cors v1.11.1 // indirect github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/templexxx/cpu v0.0.1 // indirect + github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -42,6 +44,9 @@ require ( github.com/valyala/fasthttp v1.59.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect ) + +replace fiatjaf.com/nostr => git.coracle.social/Coracle/nostrlib v0.0.0-20260209224037-43de47addbce diff --git a/go.sum b/go.sum index 9734010..0dc8e22 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -fiatjaf.com/nostr v0.0.0-20250924142401-59bd3c29fffd h1:LnbRz+TxZAROXglKFT+Lqsdqe5Pu8PG0rSpmXGnES90= -fiatjaf.com/nostr v0.0.0-20250924142401-59bd3c29fffd/go.mod h1:Nq86Jjsd0OmsOEImUg0iCcLuqM5B67Nj2eu/2dP74Ss= -fiatjaf.com/nostr v0.0.0-20251104112613-38a6ca92b954 h1:CMD8D3TgEjGhuIBNMnvZ0EXOW0JR9O3w8AI6Yuzt8Ec= -fiatjaf.com/nostr v0.0.0-20251104112613-38a6ca92b954/go.mod h1:Nq86Jjsd0OmsOEImUg0iCcLuqM5B67Nj2eu/2dP74Ss= +git.coracle.social/Coracle/nostrlib v0.0.0-20260209224037-43de47addbce h1:FG5FSVNoA37kcojItd0dKfK/o97BitPPFA5+ZUVcQT8= +git.coracle.social/Coracle/nostrlib v0.0.0-20260209224037-43de47addbce/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= @@ -14,8 +12,11 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= @@ -32,8 +33,8 @@ github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOU github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= @@ -78,6 +79,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY= +github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg= +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -91,10 +96,14 @@ github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDp github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/zooid/config.go b/zooid/config.go index 62eb04c..98d5638 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -36,6 +36,10 @@ type Config struct { AutoJoin bool `toml:"auto_join"` } `toml:"groups"` + Push struct { + Enabled bool `toml:"enabled"` + } `toml:"push"` + Management struct { Enabled bool `toml:"enabled"` Methods []string `toml:"methods"` diff --git a/zooid/groups.go b/zooid/groups.go index a0652a9..41e65bd 100644 --- a/zooid/groups.go +++ b/zooid/groups.go @@ -332,5 +332,5 @@ func (g *GroupStore) CheckWrite(event nostr.Event) string { // Middleware func (g *GroupStore) Enable(instance *Instance) { - instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, 29) + instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, "29") } diff --git a/zooid/instance.go b/zooid/instance.go index 61689bc..e814b4a 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -6,7 +6,6 @@ import ( "log" "net/http" "slices" - "strings" "fiatjaf.com/nostr" "fiatjaf.com/nostr/khatru" @@ -20,6 +19,7 @@ type Instance struct { Blossom *BlossomStore Management *ManagementStore Groups *GroupStore + Push *PushManager } func MakeInstance(filename string) (*Instance, error) { @@ -54,6 +54,13 @@ func MakeInstance(filename string) (*Instance, error) { Management: management, } + push := &PushManager{ + Config: config, + Events: events, + Management: management, + Groups: groups, + } + instance := &Instance{ Relay: relay, Config: config, @@ -61,22 +68,23 @@ func MakeInstance(filename string) (*Instance, error) { Blossom: blossom, Management: management, Groups: groups, + Push: push, } // NIP 11 info - // self := config.GetSelf() + self := config.GetSelf() owner := config.GetOwner() instance.Relay.Negentropy = true instance.Relay.Info.Name = config.Info.Name instance.Relay.Info.Icon = config.Info.Icon - // instance.Relay.Info.Self = &self + instance.Relay.Info.Self = &self instance.Relay.Info.PubKey = &owner instance.Relay.Info.Description = config.Info.Description instance.Relay.Info.Software = "https://github.com/coracle-social/zooid" instance.Relay.Info.Version = "v0.1.0" - instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, 43) + instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, "43") // Handlers @@ -91,8 +99,9 @@ func MakeInstance(filename string) (*Instance, error) { instance.Relay.OnEventSaved = instance.OnEventSaved instance.Relay.OnEphemeralEvent = instance.OnEphemeralEvent - // Todo: when there's a new version of khatru - // instance.Relay.StartExpirationManager() + // Expiration + + instance.Relay.StartExpirationManager(instance.Relay.QueryStored, instance.Relay.DeleteEvent) // HTTP request handling @@ -124,6 +133,10 @@ func MakeInstance(filename string) (*Instance, error) { instance.Groups.Enable(instance) } + if config.Push.Enabled { + instance.Push.Enable(instance) + } + // Update managed membership/admin lists instance.Management.AllowPubkey(config.GetSelf()) @@ -141,14 +154,13 @@ func MakeInstance(filename string) (*Instance, error) { } func (instance *Instance) Cleanup() { + instance.Relay.DisableExpirationManager() instance.Events.Close() } // Utility methods -func (instance *Instance) StripSignature(ctx context.Context, event nostr.Event) nostr.Event { - pubkey, _ := khatru.GetAuthed(ctx) - +func (instance *Instance) StripSignature(pubkey nostr.PubKey, event nostr.Event) nostr.Event { if instance.Config.Policy.StripSignatures && !instance.Config.CanManage(pubkey) { var zeroSig [64]byte event.Sig = zeroSig @@ -180,37 +192,6 @@ func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool { return false } -func (instance *Instance) IsInternalEvent(event nostr.Event) bool { - if event.Kind == nostr.KindApplicationSpecificData { - tag := event.Tags.Find("d") - - if tag != nil && strings.HasPrefix(tag[1], "zooid/") { - return true - } - } - - return false -} - -func (instance *Instance) IsReadOnlyEvent(event nostr.Event) bool { - readOnlyEventKinds := []nostr.Kind{ - RELAY_ADD_MEMBER, - RELAY_REMOVE_MEMBER, - RELAY_MEMBERS, - } - - return slices.Contains(readOnlyEventKinds, event.Kind) -} - -func (instance *Instance) IsWriteOnlyEvent(event nostr.Event) bool { - writeOnlyEventKinds := []nostr.Kind{ - RELAY_JOIN, - RELAY_LEAVE, - } - - return slices.Contains(writeOnlyEventKinds, event.Kind) -} - func (instance *Instance) GenerateInviteEvent(pubkey nostr.PubKey) nostr.Event { filter := nostr.Filter{ Kinds: []nostr.Kind{RELAY_INVITE}, @@ -246,7 +227,7 @@ func (instance *Instance) OnConnect(ctx context.Context) { } func (instance *Instance) PreventBroadcast(ws *khatru.WebSocket, filter nostr.Filter, event nostr.Event) bool { - return instance.IsWriteOnlyEvent(event) + return IsWriteOnlyEvent(event) } func (instance *Instance) StoreEvent(ctx context.Context, event nostr.Event) error { @@ -298,21 +279,17 @@ func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter) continue } - if !yield(instance.StripSignature(ctx, event)) { + if !yield(instance.StripSignature(pubkey, event)) { return } } for event := range instance.Events.QueryEvents(filter, 1000) { - if event.Kind == RELAY_INVITE { + if !IsReadableEvent(event) { continue } - if instance.IsInternalEvent(event) { - continue - } - - if instance.IsWriteOnlyEvent(event) { + if event.Kind == PUSH_SUBSCRIPTION && event.PubKey != pubkey { continue } @@ -320,7 +297,7 @@ func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter) continue } - if !yield(instance.StripSignature(ctx, event)) { + if !yield(instance.StripSignature(pubkey, event)) { return } } @@ -347,15 +324,19 @@ func (instance *Instance) OnEvent(ctx context.Context, event nostr.Event) (rejec return instance.Management.ValidateJoinRequest(event) } + if event.Kind == PUSH_SUBSCRIPTION { + return instance.Push.ValidatePushSubscription(event) + } + if !instance.Management.IsMember(pubkey) { return true, "restricted: you are not a member of this relay" } - if instance.IsInternalEvent(event) { + if IsInternalEvent(event) { return true, "invalid: this event's kind is not accepted" } - if instance.IsReadOnlyEvent(event) { + if IsReadOnlyEvent(event) { return true, "invalid: this event's kind is not accepted" } @@ -406,6 +387,10 @@ func (instance *Instance) OnEventSaved(ctx context.Context, event nostr.Event) { if event.Kind == nostr.KindSimpleGroupDeleteGroup { instance.Groups.DeleteGroup(h) } + + if instance.Config.Push.Enabled && !IsWriteOnlyEvent(event) { + instance.Push.HandleEvent(event) + } } func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Event) { @@ -416,4 +401,8 @@ func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Even if event.Kind == RELAY_LEAVE { instance.Management.RemoveMember(event.PubKey) } + + if instance.Config.Push.Enabled && !IsWriteOnlyEvent(event) { + instance.Push.HandleEvent(event) + } } diff --git a/zooid/push.go b/zooid/push.go new file mode 100644 index 0000000..7354c9b --- /dev/null +++ b/zooid/push.go @@ -0,0 +1,245 @@ +package zooid + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "net/url" + "slices" + "sync" + "time" + + "fiatjaf.com/nostr" +) + +// Struct definition + +type PushManager struct { + Config *Config + Events *EventStore + Management *ManagementStore + Groups *GroupStore + client *http.Client + errorCounts map[string]int // tracks consecutive errors per callback URL + errorCountMu sync.Mutex // protects errorCounts map +} + +type PushPayload struct { + ID string `json:"id"` + Relay string `json:"relay"` + Event *nostr.Event `json:"event,omitempty"` +} + +// Handlers + +func (p *PushManager) ValidatePushSubscription(event nostr.Event) (reject bool, msg string) { + if event.Tags.GetD() == "" { + return true, "invalid: missing or empty d tag" + } + + if event.Tags.FindWithValue("relay", "wss://"+p.Config.Host+"/") == nil { + return true, "invalid: relay tag does not match this relay's URL" + } + + filterTags := slices.Collect(event.Tags.FindAll("filter")) + if len(filterTags) == 0 { + return true, "invalid: at least one filter tag is required" + } + + for _, filterTag := range filterTags { + if len(filterTag) < 2 { + return true, "invalid: filter tag is malformed" + } + + var filter nostr.Filter + if err := json.Unmarshal([]byte(filterTag[1]), &filter); err != nil { + return true, "invalid: filter tag contains invalid JSON: " + err.Error() + } + } + + for ignoreTag := range event.Tags.FindAll("ignore") { + if len(ignoreTag) < 2 { + return true, "invalid: ignore tag is malformed" + } + + var filter nostr.Filter + if err := json.Unmarshal([]byte(ignoreTag[1]), &filter); err != nil { + return true, "invalid: ignore tag contains invalid JSON: " + err.Error() + } + } + + callbackTags := slices.Collect(event.Tags.FindAll("callback")) + + if len(callbackTags) < 1 { + return true, "invalid: missing callback tag" + } + + if len(callbackTags) > 1 { + return true, "invalid: too many callback tags" + } + + for _, callbackTag := range callbackTags { + if len(callbackTag) < 2 || callbackTag[1] == "" { + return true, "invalid: empty callback tag" + } + + callbackURL := callbackTag[1] + if parsedURL, err := url.Parse(callbackURL); err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + return true, "invalid: callback must be a valid HTTP or HTTPS URL" + } + } + + filter := nostr.Filter{ + Kinds: []nostr.Kind{PUSH_SUBSCRIPTION}, + Authors: []nostr.PubKey{event.PubKey}, + } + + count, err := p.Events.CountEvents(filter) + if err != nil { + return true, "internal: failed to query database" + } + + if count > 10 { + return true, "invalid: too many subscriptions registered" + } + + return false, "" +} + +func (p *PushManager) HandleEvent(event nostr.Event) { + if !IsReadableEvent(event) { + return + } + + filter := nostr.Filter{ + Kinds: []nostr.Kind{PUSH_SUBSCRIPTION}, + } + + for subscriptionEvent := range p.Events.QueryEvents(filter, 0) { + if event.PubKey == subscriptionEvent.PubKey { + continue + } + + if p.Groups.IsGroupEvent(event) && !p.Groups.CanRead(subscriptionEvent.PubKey, event) { + continue + } + + filterTags := subscriptionEvent.Tags.FindAll("filter") + matched := false + for filterTag := range filterTags { + if len(filterTag) < 2 { + continue + } + + var filter nostr.Filter + if err := json.Unmarshal([]byte(filterTag[1]), &filter); err != nil { + continue + } + + if filter.Matches(event) { + matched = true + break + } + } + + if !matched { + continue + } + + ignoreTags := subscriptionEvent.Tags.FindAll("ignore") + ignored := false + for ignoreTag := range ignoreTags { + if len(ignoreTag) < 2 { + continue + } + + var ignore nostr.Filter + if err := json.Unmarshal([]byte(ignoreTag[1]), &ignore); err != nil { + continue + } + + if ignore.Matches(event) { + ignored = true + break + } + } + + if ignored { + continue + } + + callbackTag := subscriptionEvent.Tags.Find("callback") + + if callbackTag == nil || len(callbackTag) < 2 { + continue + } + + callback := callbackTag[1] + + payload := PushPayload{ + ID: event.ID.Hex(), + Relay: "wss://" + p.Config.Host + "/", + } + + if subscriptionEvent.Tags.Find("include_event") != nil { + payload.Event = &event + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + continue + } + + go p.sendCallback(subscriptionEvent.ID, callback, payloadBytes) + } +} + +func (p *PushManager) sendCallback(subscriptionID nostr.ID, callback string, payloadBytes []byte) { + resp, err := p.client.Post(callback, "application/json", bytes.NewReader(payloadBytes)) + if resp != nil { + defer resp.Body.Close() + } + + incrementError := func() (count int) { + p.errorCountMu.Lock() + p.errorCounts[callback]++ + count = p.errorCounts[callback] + p.errorCountMu.Unlock() + + return count + } + + clearError := func() { + p.errorCountMu.Lock() + delete(p.errorCounts, callback) + p.errorCountMu.Unlock() + } + + if err == nil && resp.StatusCode == 200 { + clearError() + } else if err == nil && resp.StatusCode == 404 { + log.Printf("Callback returned 404, deleting subscription %s", subscriptionID.Hex()) + p.Events.DeleteEvent(subscriptionID) + clearError() + } else { + count := incrementError() + + if count >= 10 { + log.Printf("Deleting subscription %s due to 10 consecutive failures", subscriptionID.Hex()) + p.Events.DeleteEvent(subscriptionID) + clearError() + } + } +} + +// Middleware + +func (p *PushManager) Enable(instance *Instance) { + p.client = &http.Client{ + Timeout: 10 * time.Second, + } + p.errorCounts = make(map[string]int) + + instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, "9a") +} diff --git a/zooid/util.go b/zooid/util.go index 4067d62..5e7ad0b 100644 --- a/zooid/util.go +++ b/zooid/util.go @@ -14,10 +14,59 @@ const ( RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936 + PUSH_SUBSCRIPTION = 30390 BANNED_PUBKEYS = "zooid/banned_pubkeys" BANNED_EVENTS = "zooid/banned_events" ) +func IsInternalEvent(event nostr.Event) bool { + if event.Kind == nostr.KindApplicationSpecificData { + tag := event.Tags.Find("d") + + if tag != nil && strings.HasPrefix(tag[1], "zooid/") { + return true + } + } + + return false +} + +func IsReadOnlyEvent(event nostr.Event) bool { + readOnlyEventKinds := []nostr.Kind{ + RELAY_ADD_MEMBER, + RELAY_REMOVE_MEMBER, + RELAY_MEMBERS, + } + + return slices.Contains(readOnlyEventKinds, event.Kind) +} + +func IsWriteOnlyEvent(event nostr.Event) bool { + writeOnlyEventKinds := []nostr.Kind{ + RELAY_JOIN, + RELAY_LEAVE, + PUSH_SUBSCRIPTION, + } + + return slices.Contains(writeOnlyEventKinds, event.Kind) +} + +func IsReadableEvent(event nostr.Event) bool { + if event.Kind == RELAY_INVITE { + return false + } + + if IsInternalEvent(event) { + return false + } + + if IsWriteOnlyEvent(event) { + return false + } + + return true +} + func First[T any](s []T) T { if len(s) == 0 { var zero T