diff --git a/cmd/server/main.go b/cmd/server/main.go index 408a7cb..dadb2c6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,6 +50,7 @@ func main() { e.POST("/nip47", svc.NIP47Handler) e.POST("/nip47/webhook", svc.NIP47WebhookHandler) e.POST("/nip47/notifications", svc.NIP47NotificationHandler) + e.POST("/nip47/notifications/push", svc.NIP47PushNotificationHandler) e.POST("/publish", svc.PublishHandler) e.POST("/subscriptions", svc.SubscriptionHandler) e.DELETE("/subscriptions/:id", svc.StopSubscriptionHandler) diff --git a/go.mod b/go.mod index fc02c56..636d3be 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module http-nostr go 1.21.3 require ( + github.com/getAlby/exponent-server-sdk-golang/sdk v0.0.0-20241113053439-fb024e3a89b1 github.com/getsentry/sentry-go v0.28.1 github.com/jackc/pgx/v5 v5.6.0 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 47a5267..9c6299d 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/getAlby/exponent-server-sdk-golang/sdk v0.0.0-20241113053439-fb024e3a89b1 h1:u1rDPykjuG3DkUAeHlGby4frrynzjJAfZbuK/jLlu6k= +github.com/getAlby/exponent-server-sdk-golang/sdk v0.0.0-20241113053439-fb024e3a89b1/go.mod h1:EK6N2J42WZk795IUD9GGbKL8XAK5UjfUEvxh4d9hobY= github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= diff --git a/internal/nostr/models.go b/internal/nostr/models.go index 89e1f28..756f3b6 100644 --- a/internal/nostr/models.go +++ b/internal/nostr/models.go @@ -28,6 +28,8 @@ type Subscription struct { ID uint RelayUrl string WebhookUrl string + PushToken string + IsIOS bool Open bool Ids *[]string `gorm:"-"` Kinds *[]int `gorm:"-"` @@ -43,49 +45,36 @@ type Subscription struct { EventChan chan *nostr.Event `gorm:"-"` RequestEvent *RequestEvent `gorm:"-"` - // TODO: fix an elegant solution to store datatypes - IdsString string - KindsString string - AuthorsString string - TagsString string + IdsJson json.RawMessage `gorm:"type:jsonb"` + KindsJson json.RawMessage `gorm:"type:jsonb"` + AuthorsJson json.RawMessage `gorm:"type:jsonb"` + TagsJson json.RawMessage `gorm:"type:jsonb"` } func (s *Subscription) BeforeSave(tx *gorm.DB) error { var err error if s.Ids != nil { - var idsJson []byte - idsJson, err = json.Marshal(s.Ids) - if err != nil { - return err - } - s.IdsString = string(idsJson) + if s.IdsJson, err = json.Marshal(s.Ids); err != nil { + return err + } } if s.Kinds != nil { - var kindsJson []byte - kindsJson, err = json.Marshal(s.Kinds) - if err != nil { - return err - } - s.KindsString = string(kindsJson) + if s.KindsJson, err = json.Marshal(s.Kinds); err != nil { + return err + } } if s.Authors != nil { - var authorsJson []byte - authorsJson, err = json.Marshal(s.Authors) - if err != nil { - return err - } - s.AuthorsString = string(authorsJson) + if s.AuthorsJson, err = json.Marshal(s.Authors); err != nil { + return err + } } if s.Tags != nil { - var tagsJson []byte - tagsJson, err = json.Marshal(s.Tags) - if err != nil { - return err - } - s.TagsString = string(tagsJson) + if s.TagsJson, err = json.Marshal(s.Tags); err != nil { + return err + } } return nil @@ -93,32 +82,28 @@ func (s *Subscription) BeforeSave(tx *gorm.DB) error { func (s *Subscription) AfterFind(tx *gorm.DB) error { var err error - if s.IdsString != "" { - err = json.Unmarshal([]byte(s.IdsString), &s.Ids) - if err != nil { - return err - } + if len(s.IdsJson) > 0 { + if err = json.Unmarshal(s.IdsJson, &s.Ids); err != nil { + return err + } } - if s.KindsString != "" { - err = json.Unmarshal([]byte(s.KindsString), &s.Kinds) - if err != nil { - return err - } + if len(s.KindsJson) > 0 { + if err = json.Unmarshal(s.KindsJson, &s.Kinds); err != nil { + return err + } } - if s.AuthorsString != "" { - err = json.Unmarshal([]byte(s.AuthorsString), &s.Authors) - if err != nil { - return err - } + if len(s.AuthorsJson) > 0 { + if err = json.Unmarshal(s.AuthorsJson, &s.Authors); err != nil { + return err + } } - if s.TagsString != "" { - err = json.Unmarshal([]byte(s.TagsString), &s.Tags) - if err != nil { - return err - } + if len(s.TagsJson) > 0 { + if err = json.Unmarshal(s.TagsJson, &s.Tags); err != nil { + return err + } } return nil @@ -185,6 +170,14 @@ type NIP47NotificationRequest struct { ConnPubkey string `json:"connectionPubkey"` } +type NIP47PushNotificationRequest struct { + RelayUrl string `json:"relayUrl"` + PushToken string `json:"pushToken"` + WalletPubkey string `json:"walletPubkey"` + ConnPubkey string `json:"connectionPubkey"` + IsIOS bool `json:"isIOS"` +} + type NIP47Response struct { Event *nostr.Event `json:"event,omitempty"` State string `json:"state"` @@ -212,6 +205,13 @@ type SubscriptionResponse struct { WebhookUrl string `json:"webhookUrl"` } +type PushSubscriptionResponse struct { + SubscriptionId string `json:"subscriptionId"` + PushToken string `json:"pushToken"` + WalletPubkey string `json:"walletPubkey"` + AppPubkey string `json:"appPubkey"` +} + type StopSubscriptionResponse struct { Message string `json:"message"` State string `json:"state"` diff --git a/internal/nostr/nostr.go b/internal/nostr/nostr.go index 301af7c..a7eed77 100644 --- a/internal/nostr/nostr.go +++ b/internal/nostr/nostr.go @@ -22,6 +22,7 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" + expo "github.com/getAlby/exponent-server-sdk-golang/sdk" "github.com/jackc/pgx/v5/stdlib" sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql" gormtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/gorm.io/gorm.v1" @@ -49,6 +50,7 @@ type Service struct { subscriptions map[string]*nostr.Subscription subscriptionsMutex sync.Mutex relayMutex sync.Mutex + client *expo.PushClient } func NewService(ctx context.Context) (*Service, error) { @@ -116,6 +118,11 @@ func NewService(ctx context.Context) (*Service, error) { subscriptions := make(map[string]*nostr.Subscription) + client := expo.NewPushClient(&expo.ClientConfig{ + Host: "https://api.expo.dev", + APIURL: "/v2", + }) + var wg sync.WaitGroup svc := &Service{ Cfg: cfg, @@ -125,6 +132,7 @@ func NewService(ctx context.Context) (*Service, error) { Logger: logger, Relay: relay, subscriptions: subscriptions, + client: client, } logger.Info("Starting all open subscriptions...") @@ -139,7 +147,11 @@ func NewService(ctx context.Context) (*Service, error) { // Create a copy of the loop variable to // avoid passing address of the same variable subscription := sub - go svc.startSubscription(svc.Ctx, &subscription, nil, svc.handleSubscribedEvent) + handleEvent := svc.handleSubscribedEvent + if sub.PushToken != "" { + handleEvent = svc.handleSubscribedEventForPushNotification + } + go svc.startSubscription(svc.Ctx, &subscription, nil, handleEvent) } return svc, nil @@ -928,6 +940,7 @@ func (svc *Service) postEventToWebhook(event *nostr.Event, webhookURL string) { "event_kind": event.Kind, "webhook_url": webhookURL, }).Error("Failed to post event to webhook") + return } svc.Logger.WithFields(logrus.Fields{ diff --git a/internal/nostr/push.go b/internal/nostr/push.go new file mode 100644 index 0000000..1439f90 --- /dev/null +++ b/internal/nostr/push.go @@ -0,0 +1,163 @@ +package nostr + +import ( + "net/http" + "time" + + expo "github.com/getAlby/exponent-server-sdk-golang/sdk" + "github.com/labstack/echo/v4" + "github.com/nbd-wtf/go-nostr" + "github.com/sirupsen/logrus" +) + +func (svc *Service) NIP47PushNotificationHandler(c echo.Context) error { + var requestData NIP47PushNotificationRequest + if err := c.Bind(&requestData); err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "Error decoding notification request", + Error: err.Error(), + }) + } + + if (requestData.PushToken == "") { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "push token is empty", + Error: "no push token in request data", + }) + } + + _, err := expo.NewExponentPushToken(requestData.PushToken) + if err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "invalid push token", + Error: "invalid push token in request data", + }) + } + + if (requestData.WalletPubkey == "") { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "wallet pubkey is empty", + Error: "no wallet pubkey in request data", + }) + } + + if (requestData.ConnPubkey == "") { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "connection pubkey is empty", + Error: "no connection pubkey in request data", + }) + } + + var existingSubscriptions []Subscription + if err := svc.db.Where("push_token = ? AND open = ? AND authors_json->>0 = ? AND tags_json->'p'->>0 = ?", requestData.PushToken, true, requestData.WalletPubkey, requestData.ConnPubkey).Find(&existingSubscriptions).Error; err != nil { + svc.Logger.WithError(err).WithFields(logrus.Fields{ + "push_token": requestData.PushToken, + }).Error("Failed to check existing subscriptions") + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: "internal server error", + Error: err.Error(), + }) + } + + if len(existingSubscriptions) > 0 { + existingSubscription := existingSubscriptions[0] + svc.Logger.WithFields(logrus.Fields{ + "wallet_pubkey": requestData.WalletPubkey, + "relay_url": requestData.RelayUrl, + "push_token": requestData.PushToken, + }).Debug("Subscription already started") + return c.JSON(http.StatusOK, PushSubscriptionResponse{ + SubscriptionId: existingSubscription.Uuid, + PushToken: requestData.PushToken, + WalletPubkey: requestData.WalletPubkey, + AppPubkey: requestData.ConnPubkey, + }) + } + + svc.Logger.WithFields(logrus.Fields{ + "wallet_pubkey": requestData.WalletPubkey, + "relay_url": requestData.RelayUrl, + "push_token": requestData.PushToken, + }).Debug("Subscribing to send push notifications") + + subscription := Subscription{ + RelayUrl: requestData.RelayUrl, + PushToken: requestData.PushToken, + IsIOS: requestData.IsIOS, + Open: true, + Since: time.Now(), + Authors: &[]string{requestData.WalletPubkey}, + Kinds: &[]int{NIP_47_NOTIFICATION_KIND}, + } + + tags := make(nostr.TagMap) + (tags)["p"] = []string{requestData.ConnPubkey} + subscription.Tags = &tags + + err = svc.db.Create(&subscription).Error + if err != nil { + svc.Logger.WithError(err).WithFields(logrus.Fields{ + "wallet_pubkey": requestData.WalletPubkey, + "relay_url": requestData.RelayUrl, + "push_token": requestData.PushToken, + }).Error("Failed to store subscription") + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "Failed to store subscription", + Error: err.Error(), + }) + } + + go svc.startSubscription(svc.Ctx, &subscription, nil, svc.handleSubscribedEventForPushNotification) + + return c.JSON(http.StatusOK, PushSubscriptionResponse{ + SubscriptionId: subscription.Uuid, + PushToken: requestData.PushToken, + WalletPubkey: requestData.WalletPubkey, + AppPubkey: requestData.ConnPubkey, + }) +} + +func (svc *Service) handleSubscribedEventForPushNotification(event *nostr.Event, subscription *Subscription) { + svc.Logger.WithFields(logrus.Fields{ + "event_id": event.ID, + "event_kind": event.Kind, + "subscription_id": subscription.ID, + "relay_url": subscription.RelayUrl, + }).Debug("Received subscribed push notification") + + pushToken, _ := expo.NewExponentPushToken(subscription.PushToken) + + pushMessage := &expo.PushMessage{ + To: []expo.ExponentPushToken{pushToken}, + Data: map[string]string{ + "content": event.Content, + "appPubkey": event.Tags.GetFirst([]string{"p", ""}).Value(), + }, + } + + if subscription.IsIOS { + pushMessage.Title = "Received notification" + pushMessage.MutableContent = true + } + + response, err := svc.client.Publish(pushMessage) + if err != nil { + svc.Logger.WithError(err).WithFields(logrus.Fields{ + "push_token": subscription.PushToken, + }).Error("Failed to send push notification") + return + } + + err = response.ValidateResponse() + if err != nil { + svc.Logger.WithError(err).WithFields(logrus.Fields{ + "push_token": subscription.PushToken, + }).Error("Failed to validate expo publish response") + return + } + + svc.Logger.WithFields(logrus.Fields{ + "event_id": event.ID, + "push_token": subscription.PushToken, + }).Debug("Push notification sent successfully") +} diff --git a/migrations/202404021628_add_uuid_to_subscriptions.go b/migrations/202404021628_add_uuid_to_subscriptions.go index 8e09c78..af515c3 100644 --- a/migrations/202404021628_add_uuid_to_subscriptions.go +++ b/migrations/202404021628_add_uuid_to_subscriptions.go @@ -20,4 +20,4 @@ var _202404021628_add_uuid_to_subscriptions = &gormigrate.Migration{ } return tx.Exec("ALTER TABLE subscriptions DROP COLUMN IF EXISTS uuid").Error }, -} \ No newline at end of file +} diff --git a/migrations/202404031539_add_indexes.go b/migrations/202404031539_add_indexes.go index 8475cd3..1b8b81b 100644 --- a/migrations/202404031539_add_indexes.go +++ b/migrations/202404031539_add_indexes.go @@ -28,4 +28,4 @@ var _202404031539_add_indexes = &gormigrate.Migration{ } return nil }, -} \ No newline at end of file +} diff --git a/migrations/202411071013_add_push_token_and_is_ios_to_subscriptions.go b/migrations/202411071013_add_push_token_and_is_ios_to_subscriptions.go new file mode 100644 index 0000000..36abe7f --- /dev/null +++ b/migrations/202411071013_add_push_token_and_is_ios_to_subscriptions.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Add push_token and is_ios column to subscriptions table +var _202411071013_add_push_token_and_is_ios_to_subscriptions = &gormigrate.Migration{ + ID: "202411071013_add_push_token_and_is_ios_to_subscriptions", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN push_token TEXT").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN is_ios BOOLEAN DEFAULT NULL").Error; err != nil { + return err + } + if err := tx.Exec("CREATE INDEX IF NOT EXISTS subscriptions_push_token ON subscriptions (push_token)").Error; err != nil { + return err + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec("DROP INDEX IF EXISTS subscriptions_push_token").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN push_token").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN is_ios").Error; err != nil { + return err + } + return nil + }, +} diff --git a/migrations/202411131742_update_subscriptions_jsonb.go b/migrations/202411131742_update_subscriptions_jsonb.go new file mode 100644 index 0000000..0eeffc2 --- /dev/null +++ b/migrations/202411131742_update_subscriptions_jsonb.go @@ -0,0 +1,143 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// Update subscriptions table to use JSONB columns for ids, kinds, authors, and tags +var _202411131742_update_subscriptions_jsonb = &gormigrate.Migration{ + ID: "202411131742_update_subscriptions_jsonb", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN ids_json jsonb").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN kinds_json jsonb").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN authors_json jsonb").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN tags_json jsonb").Error; err != nil { + return err + } + + if err := tx.Exec(` + UPDATE subscriptions + SET ids_json = CASE + WHEN ids_string = '' THEN '[]'::jsonb + ELSE ids_string::jsonb + END + WHERE ids_string IS NOT NULL + `).Error; err != nil { + return err + } + + if err := tx.Exec(` + UPDATE subscriptions + SET kinds_json = CASE + WHEN kinds_string = '' THEN '[]'::jsonb + ELSE kinds_string::jsonb + END + WHERE kinds_string IS NOT NULL + `).Error; err != nil { + return err + } + + if err := tx.Exec(` + UPDATE subscriptions + SET authors_json = CASE + WHEN authors_string = '' THEN '[]'::jsonb + ELSE authors_string::jsonb + END + WHERE authors_string IS NOT NULL + `).Error; err != nil { + return err + } + + if err := tx.Exec(` + UPDATE subscriptions + SET tags_json = CASE + WHEN tags_string = '' THEN '{}'::jsonb + ELSE tags_string::jsonb + END + WHERE tags_string IS NOT NULL + `).Error; err != nil { + return err + } + + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN ids_string").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN kinds_string").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN authors_string").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN tags_string").Error; err != nil { + return err + } + + if err := tx.Exec("CREATE INDEX IF NOT EXISTS subscriptions_open ON subscriptions (open)").Error; err != nil { + return err + } + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_subscriptions_authors_json ON subscriptions USING gin (authors_json)").Error; err != nil { + return err + } + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_subscriptions_tags_json ON subscriptions USING gin (tags_json)").Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Exec("DROP INDEX IF EXISTS idx_subscriptions_authors_json").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_subscriptions_tags_json").Error; err != nil { + return err + } + + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN ids_string TEXT").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN kinds_string TEXT").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN authors_string TEXT").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions ADD COLUMN tags_string TEXT").Error; err != nil { + return err + } + + if err := tx.Exec("UPDATE subscriptions SET ids_string = ids_json::text WHERE ids IS NOT NULL").Error; err != nil { + return err + } + if err := tx.Exec("UPDATE subscriptions SET kinds_string = kinds_json::text WHERE kinds IS NOT NULL").Error; err != nil { + return err + } + if err := tx.Exec("UPDATE subscriptions SET authors_string = authors_json::text WHERE authors IS NOT NULL").Error; err != nil { + return err + } + if err := tx.Exec("UPDATE subscriptions SET tags_string = tags_json::text WHERE tags IS NOT NULL").Error; err != nil { + return err + } + + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN ids_json").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN kinds_json").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN authors_json").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE subscriptions DROP COLUMN tags_json").Error; err != nil { + return err + } + + return nil + }, +} diff --git a/migrations/migrate.go b/migrations/migrate.go index 444adee..ea1f3f8 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -12,7 +12,9 @@ func Migrate(db *gorm.DB) error { _202404021628_add_uuid_to_subscriptions, _202404031539_add_indexes, _202407171220_add_response_received_at_to_request_events, + _202411071013_add_push_token_and_is_ios_to_subscriptions, + _202411131742_update_subscriptions_jsonb, }) return m.Migrate() -} \ No newline at end of file +}