From 5e9ddeb763424445ba323f5445d252fb8c79dbfd Mon Sep 17 00:00:00 2001 From: KaviiSuri Date: Thu, 8 Jun 2023 02:31:35 +0530 Subject: [PATCH] feat(service): Add Web Push (#441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kavii Suri Co-authored-by: Niko Köser --- .golangci.yml | 1 - README.md | 65 +-- go.mod | 3 + go.sum | 5 + service/webpush/README.md | 51 +++ service/webpush/doc.go | 41 ++ service/webpush/webpush.go | 189 +++++++++ service/webpush/webpush_test.go | 676 ++++++++++++++++++++++++++++++++ 8 files changed, 998 insertions(+), 33 deletions(-) create mode 100644 service/webpush/README.md create mode 100644 service/webpush/doc.go create mode 100644 service/webpush/webpush.go create mode 100644 service/webpush/webpush_test.go diff --git a/.golangci.yml b/.golangci.yml index 5dd6b158..51e2c6ba 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,7 +9,6 @@ linters: enable: - 'asciicheck' - 'bodyclose' - - 'depguard' - 'dogsled' - 'errcheck' - 'errorlint' diff --git a/README.md b/README.md index 9debe568..1b66c4a4 100644 --- a/README.md +++ b/README.md @@ -77,38 +77,39 @@ Yes, please! Contributions of all kinds are very welcome! Feel free to check our > Click [here](https://github.com/nikoksr/notify/issues/new?assignees=&labels=affects%2Fservices%2C+good+first+issue%2C+hacktoberfest%2C+help+wanted%2C+type%2Fenhancement%2C+up+for+grabs&template=service-request.md&title=feat%28service%29%3A+Add+%5BSERVICE+NAME%5D+service) to request a missing service. -| Service | Path | Credits | Status | -|--------------------------------------------------------------------------------|------------------------------------------|-------------------------------------------------------------------------------------------------|:------------------:| -| [Amazon SES](https://aws.amazon.com/ses) | [service/amazonses](service/amazonses) | [aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | :heavy_check_mark: | -| [Amazon SNS](https://aws.amazon.com/sns) | [service/amazonsns](service/amazonsns) | [aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | :heavy_check_mark: | -| [Bark](https://apps.apple.com/us/app/bark-customed-notifications/id1403753865) | [service/bark](service/bark) | - | :heavy_check_mark: | -| [DingTalk](https://www.dingtalk.com) | [service/dinding](service/dingding) | [blinkbean/dingtalk](https://github.com/blinkbean/dingtalk) | :heavy_check_mark: | -| [Discord](https://discord.com) | [service/discord](service/discord) | [bwmarrin/discordgo](https://github.com/bwmarrin/discordgo) | :heavy_check_mark: | -| [Email](https://wikipedia.org/wiki/Email) | [service/mail](service/mail) | [jordan-wright/email](https://github.com/jordan-wright/email) | :heavy_check_mark: | -| [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) | [service/fcm](service/fcm) | [appleboy/go-fcm](https://github.com/appleboy/go-fcm) | :heavy_check_mark: | - | [Google Chat](https://workspace.google.com/intl/en/products/chat/) | [service/googlechat](service/googlechat) | [googleapis/google-api-go-client](https://google.golang.org/api/chat/v1) | :heavy_check_mark: | -| [HTTP](https://wikipedia.org/wiki/Hypertext_Transfer_Protocol) | [service/http](service/http) | - | :heavy_check_mark: | -| [Lark](https://www.larksuite.com/) | [service/lark](service/lark) | [go-lark/lark](https://github.com/go-lark/lark) | :heavy_check_mark: | -| [Line](https://line.me) | [service/line](service/line) | [line/line-bot-sdk-go](https://github.com/line/line-bot-sdk-go) | :heavy_check_mark: | -| [Line Notify](https://notify-bot.line.me) | [service/line](service/line) | [utahta/go-linenotify](https://github.com/utahta/go-linenotify) | :heavy_check_mark: | -| [Mailgun](https://www.mailgun.com) | [service/mailgun](service/mailgun) | [mailgun/mailgun-go](https://github.com/mailgun/mailgun-go) | :heavy_check_mark: | -| [Matrix](https://www.matrix.org) | [service/matrix](service/matrix) | [mautrix/go](https://github.com/mautrix/go) | :heavy_check_mark: | -| [Microsoft Teams](https://www.microsoft.com/microsoft-teams) | [service/msteams](service/msteams) | [atc0005/go-teams-notify](https://github.com/atc0005/go-teams-notify) | :heavy_check_mark: | -| [Plivo](https://www.plivo.com) | [service/plivo](service/plivo) | [plivo/plivo-go](https://github.com/plivo/plivo-go) | :heavy_check_mark: | -| [Pushover](https://pushover.net/) | [service/pushover](service/pushover) | [gregdel/pushover](https://github.com/gregdel/pushover) | :heavy_check_mark: | -| [Pushbullet](https://www.pushbullet.com) | [service/pushbullet](service/pushbullet) | [cschomburg/go-pushbullet](https://github.com/cschomburg/go-pushbullet) | :heavy_check_mark: | -| [Reddit](https://www.reddit.com) | [service/reddit](service/reddit) | [vartanbeno/go-reddit](https://github.com/vartanbeno/go-reddit) | :heavy_check_mark: | -| [RocketChat](https://rocket.chat) | [service/rocketchat](service/rocketchat) | [RocketChat/Rocket.Chat.Go.SDK](https://github.com/RocketChat/Rocket.Chat.Go.SDK) | :heavy_check_mark: | -| [SendGrid](https://sendgrid.com) | [service/sendgrid](service/sendgrid) | [sendgrid/sendgrid-go](https://github.com/sendgrid/sendgrid-go) | :heavy_check_mark: | -| [Slack](https://slack.com) | [service/slack](service/slack) | [slack-go/slack](https://github.com/slack-go/slack) | :heavy_check_mark: | -| [Syslog](https://wikipedia.org/wiki/Syslog) | [service/syslog](service/syslog) | [log/syslog](https://pkg.go.dev/log/syslog) | :heavy_check_mark: | -| [Telegram](https://telegram.org) | [service/telegram](service/telegram) | [go-telegram-bot-api/telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api) | :heavy_check_mark: | -| [TextMagic](https://www.textmagic.com) | [service/textmagic](service/textmagic) | [textmagic/textmagic-rest-go-v2](https://github.com/textmagic/textmagic-rest-go-v2) | :heavy_check_mark: | -| [Twilio](https://www.twilio.com/) | [service/twilio](service/twilio) | [kevinburke/twilio-go](https://github.com/kevinburke/twilio-go) | :heavy_check_mark: | -| [Twitter](https://twitter.com) | [service/twitter](service/twitter) | [drswork/go-twitter](https://github.com/drswork/go-twitter) | :heavy_check_mark: | -| [Viber](https://www.viber.com) | [service/viber](service/viber) | [mileusna/viber](https://github.com/mileusna/viber) | :heavy_check_mark: | -| [WeChat](https://www.wechat.com) | [service/wechat](service/wechat) | [silenceper/wechat](https://github.com/silenceper/wechat) | :heavy_check_mark: | -| [WhatsApp](https://www.whatsapp.com) | [service/whatsapp](service/whatsapp) | [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) | :x: | +| Service | Path | Credits | Status | +|-----------------------------------------------------------------------------------|------------------------------------------|-------------------------------------------------------------------------------------------------|:------------------:| +| [Amazon SES](https://aws.amazon.com/ses) | [service/amazonses](service/amazonses) | [aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | :heavy_check_mark: | +| [Amazon SNS](https://aws.amazon.com/sns) | [service/amazonsns](service/amazonsns) | [aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | :heavy_check_mark: | +| [Bark](https://apps.apple.com/us/app/bark-customed-notifications/id1403753865) | [service/bark](service/bark) | - | :heavy_check_mark: | +| [DingTalk](https://www.dingtalk.com) | [service/dinding](service/dingding) | [blinkbean/dingtalk](https://github.com/blinkbean/dingtalk) | :heavy_check_mark: | +| [Discord](https://discord.com) | [service/discord](service/discord) | [bwmarrin/discordgo](https://github.com/bwmarrin/discordgo) | :heavy_check_mark: | +| [Email](https://wikipedia.org/wiki/Email) | [service/mail](service/mail) | [jordan-wright/email](https://github.com/jordan-wright/email) | :heavy_check_mark: | +| [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) | [service/fcm](service/fcm) | [appleboy/go-fcm](https://github.com/appleboy/go-fcm) | :heavy_check_mark: | + | [Google Chat](https://workspace.google.com/intl/en/products/chat/) | [service/googlechat](service/googlechat) | [googleapis/google-api-go-client](https://google.golang.org/api/chat/v1) | :heavy_check_mark: | +| [HTTP](https://wikipedia.org/wiki/Hypertext_Transfer_Protocol) | [service/http](service/http) | - | :heavy_check_mark: | +| [Lark](https://www.larksuite.com/) | [service/lark](service/lark) | [go-lark/lark](https://github.com/go-lark/lark) | :heavy_check_mark: | +| [Line](https://line.me) | [service/line](service/line) | [line/line-bot-sdk-go](https://github.com/line/line-bot-sdk-go) | :heavy_check_mark: | +| [Line Notify](https://notify-bot.line.me) | [service/line](service/line) | [utahta/go-linenotify](https://github.com/utahta/go-linenotify) | :heavy_check_mark: | +| [Mailgun](https://www.mailgun.com) | [service/mailgun](service/mailgun) | [mailgun/mailgun-go](https://github.com/mailgun/mailgun-go) | :heavy_check_mark: | +| [Matrix](https://www.matrix.org) | [service/matrix](service/matrix) | [mautrix/go](https://github.com/mautrix/go) | :heavy_check_mark: | +| [Microsoft Teams](https://www.microsoft.com/microsoft-teams) | [service/msteams](service/msteams) | [atc0005/go-teams-notify](https://github.com/atc0005/go-teams-notify) | :heavy_check_mark: | +| [Plivo](https://www.plivo.com) | [service/plivo](service/plivo) | [plivo/plivo-go](https://github.com/plivo/plivo-go) | :heavy_check_mark: | +| [Pushover](https://pushover.net/) | [service/pushover](service/pushover) | [gregdel/pushover](https://github.com/gregdel/pushover) | :heavy_check_mark: | +| [Pushbullet](https://www.pushbullet.com) | [service/pushbullet](service/pushbullet) | [cschomburg/go-pushbullet](https://github.com/cschomburg/go-pushbullet) | :heavy_check_mark: | +| [Reddit](https://www.reddit.com) | [service/reddit](service/reddit) | [vartanbeno/go-reddit](https://github.com/vartanbeno/go-reddit) | :heavy_check_mark: | +| [RocketChat](https://rocket.chat) | [service/rocketchat](service/rocketchat) | [RocketChat/Rocket.Chat.Go.SDK](https://github.com/RocketChat/Rocket.Chat.Go.SDK) | :heavy_check_mark: | +| [SendGrid](https://sendgrid.com) | [service/sendgrid](service/sendgrid) | [sendgrid/sendgrid-go](https://github.com/sendgrid/sendgrid-go) | :heavy_check_mark: | +| [Slack](https://slack.com) | [service/slack](service/slack) | [slack-go/slack](https://github.com/slack-go/slack) | :heavy_check_mark: | +| [Syslog](https://wikipedia.org/wiki/Syslog) | [service/syslog](service/syslog) | [log/syslog](https://pkg.go.dev/log/syslog) | :heavy_check_mark: | +| [Telegram](https://telegram.org) | [service/telegram](service/telegram) | [go-telegram-bot-api/telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api) | :heavy_check_mark: | +| [TextMagic](https://www.textmagic.com) | [service/textmagic](service/textmagic) | [textmagic/textmagic-rest-go-v2](https://github.com/textmagic/textmagic-rest-go-v2) | :heavy_check_mark: | +| [Twilio](https://www.twilio.com/) | [service/twilio](service/twilio) | [kevinburke/twilio-go](https://github.com/kevinburke/twilio-go) | :heavy_check_mark: | +| [Twitter](https://twitter.com) | [service/twitter](service/twitter) | [drswork/go-twitter](https://github.com/drswork/go-twitter) | :heavy_check_mark: | +| [Viber](https://www.viber.com) | [service/viber](service/viber) | [mileusna/viber](https://github.com/mileusna/viber) | :heavy_check_mark: | +| [WeChat](https://www.wechat.com) | [service/wechat](service/wechat) | [silenceper/wechat](https://github.com/silenceper/wechat) | :heavy_check_mark: | +| [Webpush Notification](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) | [service/webpush](service/webpush) | [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go/) | :heavy_check_mark: | +| [WhatsApp](https://www.whatsapp.com) | [service/whatsapp](service/whatsapp) | [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) | :x: | ## Special Thanks diff --git a/go.mod b/go.mod index 1199b862..65f8503c 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( ) require ( + github.com/SherClockHolmes/webpush-go v1.2.0 github.com/appleboy/go-fcm v0.1.5 github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 github.com/go-lark/lark v1.7.4 @@ -38,6 +39,8 @@ require ( maunium.net/go/mautrix v0.15.2 ) +require github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + require ( github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/vartanbeno/go-reddit/v2 v2.0.1 diff --git a/go.sum b/go.sum index 57b909f4..21e46f62 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo= github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc= github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20221121042443-a3fd332d56d9 h1:vuu1KBsr6l7XU3CHsWESP/4B1SNd+VZkrgeFZsUXrsY= github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20221121042443-a3fd332d56d9/go.mod h1:rjP7sIipbZcagro/6TCk6X0ZeFT2eyudH5+fve/cbBA= +github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA= +github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M= @@ -117,6 +119,8 @@ github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -309,6 +313,7 @@ github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaN go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/service/webpush/README.md b/service/webpush/README.md new file mode 100644 index 00000000..0d1c93fc --- /dev/null +++ b/service/webpush/README.md @@ -0,0 +1,51 @@ +# Webpush Notifications + +[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/nikoksr/notify/service/webpush) + + +## Prerequisites + +Generate VAPID Public and Private Keys for the notification service. This can be done using many tools, one of which is [`GenerateVAPIDKeys`](https://pkg.go.dev/github.com/SherClockHolmes/webpush-go#GenerateVAPIDKeys) from [webpush-go](https://github.com/SherClockHolmes/webpush-go/). + +### Compatibility + +This service is compatible with the [Web Push Protocol](https://tools.ietf.org/html/rfc8030) and [VAPID](https://tools.ietf.org/html/rfc8292). + +For a list of compatible browsers, see [this](https://caniuse.com/push-api) for the Push API and [this](https://caniuse.com/notifications) for the Web Notifications. + +## Usage +```go +package main + +import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/webpush" +) + +const vapidPublicKey = "..." // Add a vapidPublicKey +const vapidPrivateKey = "..." // Add a vapidPrivateKey + +func main() { + subscription := webpush.Subscription{ + Endpoint: "https://your-endpoint", + Keys: { + Auth: "...", + P256dh: "...", + }, + } + + webpushSvc := webpush.New(vapidPublicKey, vapidPrivateKey) + webpushSvc.AddReceivers(subscription) + + notifier := notify.NewWithServices(webpushSvc) + + if err := notifier.Send(context.Background(), "TEST", "Message using golang notifier library"); err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("Notification sent successfully") +} +``` diff --git a/service/webpush/doc.go b/service/webpush/doc.go new file mode 100644 index 00000000..f409402e --- /dev/null +++ b/service/webpush/doc.go @@ -0,0 +1,41 @@ +/* +Package webpush provides a service for sending messages to viber. + +Usage: + + package main + + import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/webpush" + + ) + + const vapidPublicKey = "..." // Add a vapidPublicKey + const vapidPrivateKey = "..." // Add a vapidPrivateKey + + func main() { + subscription := webpush.Subscription{ + Endpoint: "https://your-endpoint", + Keys: { + Auth: "...", + P256dh: "...", + }, + } + + webpushSvc := webpush.New(vapidPublicKey, vapidPrivateKey) + webpushSvc.AddReceivers(subscription) + + notifier := notify.NewWithServices(webpushSvc) + + if err := notifier.Send(context.Background(), "TEST", "Message using golang notifier library"); err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("Notification sent successfully") + } +*/ +package webpush diff --git a/service/webpush/webpush.go b/service/webpush/webpush.go new file mode 100644 index 00000000..053f9663 --- /dev/null +++ b/service/webpush/webpush.go @@ -0,0 +1,189 @@ +package webpush + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/SherClockHolmes/webpush-go" + "github.com/pkg/errors" +) + +type ( + // Urgency indicates the importance of the message. It's a type alias for webpush.Urgency. + Urgency = webpush.Urgency + + // Options are optional settings for the sending of a message. It's a type alias for webpush.Options. + Options = webpush.Options + + // Subscription is a JSON representation of a webpush subscription. It's a type alias for webpush.Subscription. + Subscription = webpush.Subscription + + // messagePayload is the JSON payload that is sent to the webpush endpoint. + messagePayload struct { + Subject string `json:"subject"` + Message string `json:"message"` + Data map[string]interface{} `json:"data,omitempty"` + } + + msgDataKey struct{} + msgOptionsKey struct{} +) + +// optionsKey is used as a context.Context key to optionally add options to the messagePayload payload. +var optionsKey = msgOptionsKey{} + +// dataKey is used as a context.Context key to optionally add data to the messagePayload payload. +var dataKey = msgDataKey{} + +// These are exposed Urgency constants from the webpush package. +var ( + // UrgencyVeryLow requires device state: on power and Wi-Fi + UrgencyVeryLow Urgency = webpush.UrgencyVeryLow + + // UrgencyLow requires device state: on either power or Wi-Fi + UrgencyLow Urgency = webpush.UrgencyLow + + // UrgencyNormal excludes device state: low battery + UrgencyNormal Urgency = webpush.UrgencyNormal + + // UrgencyHigh admits device state: low battery + UrgencyHigh Urgency = webpush.UrgencyHigh +) + +// Service encapsulates the webpush notification system along with the internal state +type Service struct { + subscriptions []webpush.Subscription + options webpush.Options +} + +// New returns a new instance of the Service +func New(vapidPublicKey string, vapidPrivateKey string) *Service { + return &Service{ + subscriptions: []webpush.Subscription{}, + options: webpush.Options{ + VAPIDPublicKey: vapidPublicKey, + VAPIDPrivateKey: vapidPrivateKey, + }, + } +} + +// AddReceivers adds one or more subscriptions to the Service. +func (s *Service) AddReceivers(subscriptions ...Subscription) { + s.subscriptions = append(s.subscriptions, subscriptions...) +} + +// withOptions returns a new Options struct with the incoming options merged with the Service's options. The incoming +// options take precedence, except for the VAPID keys. Existing VAPID keys are only replaced if the incoming VAPID keys +// are not empty. +func (s *Service) withOptions(options Options) Options { + if options.VAPIDPublicKey == "" { + options.VAPIDPublicKey = s.options.VAPIDPublicKey + } + if options.VAPIDPrivateKey == "" { + options.VAPIDPrivateKey = s.options.VAPIDPrivateKey + } + + return options +} + +// WithOptions binds the options to the context so that they will be used by the Service.Send method automatically. Options +// are settings that allow you to customize the sending behavior of a message. +func WithOptions(ctx context.Context, options Options) context.Context { + return context.WithValue(ctx, optionsKey, options) +} + +func optionsFromContext(ctx context.Context) Options { + if options, ok := ctx.Value(optionsKey).(Options); ok { + return options + } + + return Options{} +} + +// WithData binds the data to the context so that it will be used by the Service.Send method automatically. Data is a +// map[string]interface{} and acts as a metadata field that is sent along with the message payload. +func WithData(ctx context.Context, data map[string]interface{}) context.Context { + return context.WithValue(ctx, dataKey, data) +} + +func dataFromContext(ctx context.Context) map[string]interface{} { + if data, ok := ctx.Value(dataKey).(map[string]interface{}); ok { + return data + } + + return map[string]interface{}{} +} + +// payloadFromContext returns a json encoded byte array of the messagePayload payload that is ready to be sent to the +// webpush endpoint. Internally, it uses the messagePayload and data from the context, and it combines it with the +// subject and message arguments into a single messagePayload. +func payloadFromContext(ctx context.Context, subject, message string) ([]byte, error) { + payload := messagePayload{ + Subject: subject, + Message: message, + } + + payload.Data = dataFromContext(ctx) // Load optional data + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "failed to serialize messagePayload") + } + + return payloadBytes, nil +} + +// send is a wrapper that makes it primarily easier to defer the closing of the response body. +func (s *Service) send(ctx context.Context, message []byte, subscription *Subscription, options *Options) error { + res, err := webpush.SendNotificationWithContext(ctx, message, subscription, options) + if err != nil { + return errors.Wrapf(err, "failed to send messagePayload to webpush subscription %s", subscription.Endpoint) + } + defer res.Body.Close() + + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { + return nil // Everything is fine + } + + // Make sure to produce a helpful error message + + baseErr := errors.Errorf( + "failed to send message to webpush subscription %s: unexpected status code %d", + subscription.Endpoint, res.StatusCode, + ) + + body, err := io.ReadAll(res.Body) + if err != nil { + baseErr = errors.Wrap(errors.Wrap(err, "failed to read response body"), baseErr.Error()) + } else { + baseErr = fmt.Errorf("%w: %s", baseErr, body) + } + + return baseErr +} + +// Send sends a message to all the webpush subscriptions that have been added to the Service. The subject and message +// arguments are the subject and message of the messagePayload payload. The context can be used to optionally add +// options and data to the messagePayload payload. See the WithOptions and WithData functions. +func (s *Service) Send(ctx context.Context, subject, message string) error { + // Get the options from the context and merge them with the service's initial options + options := optionsFromContext(ctx) + options = s.withOptions(options) + + payload, err := payloadFromContext(ctx, subject, message) + if err != nil { + return err + } + + for _, subscription := range s.subscriptions { + subscription := subscription // Capture the subscription in the closure + if err := s.send(ctx, payload, &subscription, &options); err != nil { + return err + } + } + + return nil +} diff --git a/service/webpush/webpush_test.go b/service/webpush/webpush_test.go new file mode 100644 index 00000000..6e9e2d4e --- /dev/null +++ b/service/webpush/webpush_test.go @@ -0,0 +1,676 @@ +package webpush + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + + "github.com/SherClockHolmes/webpush-go" + "github.com/google/go-cmp/cmp" +) + +// Allows us to simulate an error returned from the server on a per-request basis +const headerTestError = "X-Test-Error" + +// checkFunc is a function that checks a request and returns an error if the check fails +type checkFunc func(r *http.Request) error + +// checkMethod returns a checkFunc that checks the request method +func checkMethod(method string) func(r *http.Request) error { + return func(r *http.Request) error { + if r.Method != method { + return fmt.Errorf("unexpected method: %s", r.Method) + } + return nil + } +} + +// checkHeader returns a checkFunc that checks the request header +func checkHeader(key, value string) func(r *http.Request) error { + return func(r *http.Request) error { + if r.Header.Get(key) != value { + return fmt.Errorf("unexpected %s header: %s", key, r.Header.Get(key)) + } + return nil + } +} + +// defaultChecks is the default set of checks used for testing +var defaultChecks = []checkFunc{ + checkMethod("POST"), + checkHeader("Content-Type", "application/octet-stream"), + checkHeader("Content-Encoding", "aes128gcm"), +} + +// newWebpushHandlerWithChecks returns a new http.Handler that checks the request against the given checks and returns +// a 400 if any of them fail. +func newWebpushHandlerWithChecks(checks ...checkFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, check := range checks { + if err := check(r); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + } + + // This allows us to simulate an error returned from the server on a per-request basis + if r.Header.Get(headerTestError) != "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(r.Header.Get(headerTestError))) + return + } + + w.WriteHeader(http.StatusCreated) + }) +} + +var vapidPublicKey, vapidPrivateKey string + +// TestMain sets up a test server to handle the requests +func TestMain(m *testing.M) { + // Generate a VAPID key pair + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Fatal(err) + } + + vapidPublicKey = publicKey + vapidPrivateKey = privateKey + + os.Exit(m.Run()) +} + +func getValidSubscription() Subscription { + return Subscription{ + Keys: webpush.Keys{ + P256dh: "BNNL5ZaTfK81qhXOx23-wewhigUeFb632jN6LvRWCFH1ubQr77FE_9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk", + Auth: "zqbxT6JKstKSY9JKibZLSQ", + }, + } +} + +func getInvalidSubscription() Subscription { + return Subscription{ + Keys: webpush.Keys{ + P256dh: "AAA", + Auth: "BBB", + }, + } +} + +func TestService_Send(t *testing.T) { + t.Parallel() + + type fields struct { + subscriptions []webpush.Subscription + vapidPublicKey string + vapidPrivateKey string + } + type args struct { + subject string + message string + options webpush.Options // Bind those to the context + } + tests := []struct { + name string + fields fields + args args + handler http.Handler + wantErr bool + }{ + { + name: "Send a message with options", + fields: fields{ + subscriptions: []webpush.Subscription{ + getValidSubscription(), + }, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{ + TTL: 60, // Should set the TTL header + Topic: "test-topic", // Should set the Topic header + Urgency: UrgencyHigh, // Should set the Urgency header + }, + }, + handler: newWebpushHandlerWithChecks( + append( + defaultChecks, + checkHeader("TTL", "60"), + checkHeader("Topic", "test-topic"), + checkHeader("Urgency", string(UrgencyHigh)), + )..., + ), + wantErr: false, + }, + { + name: "Send a message with no options", + fields: fields{ + subscriptions: []webpush.Subscription{ + getValidSubscription(), + }, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, + }, + { + name: "Send a message with no options and no subscriptions", + fields: fields{ + subscriptions: []webpush.Subscription{}, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, + }, + { + name: "Send a message with no options and no subscriptions", + fields: fields{ + subscriptions: []webpush.Subscription{}, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, + }, + { + name: "Send a message with no vapid public key", + fields: fields{ + subscriptions: []webpush.Subscription{ + getValidSubscription(), + }, + vapidPublicKey: "", + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, // Yes, does not cause an error + }, + { + name: "Send a message with no vapid private key", + fields: fields{ + subscriptions: []webpush.Subscription{ + getValidSubscription(), + }, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: "", + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, // Yes, does not cause an error + }, + { + name: "Send a message with no vapid keys", + fields: fields{ + subscriptions: []webpush.Subscription{ + getValidSubscription(), + }, + vapidPublicKey: "", + vapidPrivateKey: "", + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: false, // Yes, does not cause an error + }, + { + name: "Send a message with invalid subscription", + fields: fields{ + subscriptions: []webpush.Subscription{ + getInvalidSubscription(), + }, + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + subject: "subject", + message: "message", + options: webpush.Options{}, + }, + handler: newWebpushHandlerWithChecks(defaultChecks...), + wantErr: true, + }, + } + + //nolint:paralleltest + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeWebpushServer := httptest.NewServer(tt.handler) + defer fakeWebpushServer.Close() + + s := New(tt.fields.vapidPublicKey, tt.fields.vapidPrivateKey) + + for _, subscription := range tt.fields.subscriptions { + subscription.Endpoint = fakeWebpushServer.URL + s.AddReceivers(subscription) + } + + ctx := WithOptions(context.Background(), tt.args.options) + err := s.Send(ctx, tt.args.subject, tt.args.message) + if (err != nil) != tt.wantErr { + t.Errorf("Service.Send() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestService_withOptions(t *testing.T) { + t.Parallel() + + type fields struct { + vapidPublicKey string + vapidPrivateKey string + } + type args struct { + options Options + } + tests := []struct { + name string + fields fields + args args + want Options + }{ + { + name: "with options but no VAPID keys", + fields: fields{ + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + options: Options{ + RecordSize: 4096, + Subscriber: "test-subscriber", + Topic: "test-topic", + TTL: 100, + Urgency: UrgencyHigh, + VAPIDPublicKey: "", // should be ignored + VAPIDPrivateKey: "", // should be ignored + }, + }, + want: Options{ + RecordSize: 4096, + Subscriber: "test-subscriber", + Topic: "test-topic", + TTL: 100, + Urgency: UrgencyHigh, + VAPIDPublicKey: vapidPublicKey, + VAPIDPrivateKey: vapidPrivateKey, + }, + }, + { + name: "with options and VAPID keys", + fields: fields{ + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + args: args{ + options: Options{ + RecordSize: 4096, + Subscriber: "test-subscriber", + Topic: "test-topic", + TTL: 100, + Urgency: UrgencyHigh, + VAPIDPublicKey: "test-public-key", // should be used + VAPIDPrivateKey: "test-private-key", // should be used + }, + }, + want: Options{ + RecordSize: 4096, + Subscriber: "test-subscriber", + Topic: "test-topic", + TTL: 100, + Urgency: UrgencyHigh, + VAPIDPublicKey: "test-public-key", + VAPIDPrivateKey: "test-private-key", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := New(tt.fields.vapidPublicKey, tt.fields.vapidPrivateKey) + + if got := s.withOptions(tt.args.options); !reflect.DeepEqual(got, tt.want) { + t.Errorf("withOptions() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + + type args struct { + vapidPublicKey string + vapidPrivateKey string + } + tests := []struct { + name string + args args + want *Service + }{ + { + name: "New with VAPID keys", + args: args{ + vapidPublicKey: vapidPublicKey, + vapidPrivateKey: vapidPrivateKey, + }, + want: &Service{ + subscriptions: []webpush.Subscription{}, + options: Options{ + VAPIDPublicKey: vapidPublicKey, + VAPIDPrivateKey: vapidPrivateKey, + }, + }, + }, + { + name: "New without VAPID keys", + args: args{ + vapidPublicKey: "", + vapidPrivateKey: "", + }, + want: &Service{ + subscriptions: []webpush.Subscription{}, + options: Options{ + VAPIDPublicKey: "", + VAPIDPrivateKey: "", + }, + }, + }, + } + + opts := []cmp.Option{ + cmp.AllowUnexported(Service{}), + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := New(tt.args.vapidPublicKey, tt.args.vapidPrivateKey) + + if diff := cmp.Diff(got, tt.want, opts...); diff != "" { + t.Errorf("New() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestService_AddReceivers(t *testing.T) { + t.Parallel() + + type fields struct { + subscriptions []webpush.Subscription + } + type args struct { + subscriptions []Subscription + } + tests := []struct { + name string + fields fields + args args + }{ + { + name: "AddReceivers", + fields: fields{ + subscriptions: []webpush.Subscription{}, + }, + args: args{ + subscriptions: []Subscription{ + { + Endpoint: "https://fcm.googleapis.com/fcm/send/dQw4w9WgXcQ", + Keys: webpush.Keys{ + Auth: "auth", + P256dh: "p256dh", + }, + }, + }, + }, + }, + { + name: "AddReceivers with multiple subscriptions", + fields: fields{ + subscriptions: []webpush.Subscription{}, + }, + args: args{ + subscriptions: []Subscription{ + { + Endpoint: "https://example.com/push1", + Keys: webpush.Keys{ + Auth: "auth", + P256dh: "p256dh", + }, + }, + { + Endpoint: "https://example.com/push2", + Keys: webpush.Keys{ + Auth: "auth", + P256dh: "p256dh", + }, + }, + { + Endpoint: "https://example.com/push3", + Keys: webpush.Keys{ + Auth: "auth", + P256dh: "p256dh", + }, + }, + }, + }, + }, + } + + opts := []cmp.Option{ + cmp.AllowUnexported(Service{}), + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := New("", "") + s.AddReceivers(tt.args.subscriptions...) + + if diff := cmp.Diff(s.subscriptions, tt.args.subscriptions, opts...); diff != "" { + t.Errorf("AddReceivers() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_ContextBinding(t *testing.T) { + t.Parallel() + + type fields struct { + ctx context.Context + } + type args struct { + data map[string]interface{} + options Options + } + tests := []struct { + name string + fields fields + args args + }{ + { + name: "Bind data", + fields: fields{ + ctx: context.Background(), + }, + args: args{ + data: map[string]interface{}{ + "title": "Test", + }, + }, + }, + { + name: "Bind options", + fields: fields{ + ctx: context.Background(), + }, + args: args{ + options: Options{ + Topic: "test", + Urgency: UrgencyHigh, + TTL: 60, + }, + }, + }, + { + name: "Bind data and options", + fields: fields{ + ctx: context.Background(), + }, + args: args{ + data: map[string]interface{}{ + "title": "Test", + }, + options: Options{ + Topic: "test", + Urgency: UrgencyHigh, + TTL: 60, + }, + }, + }, + { + name: "Bind nothing", // Make sure nothing panics + fields: fields{ + ctx: context.Background(), + }, + args: args{}, + }, + } + + opts := []cmp.Option{ + cmp.AllowUnexported(Service{}), + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotCtx := WithData(tt.fields.ctx, tt.args.data) + gotCtx = WithOptions(gotCtx, tt.args.options) + + if gotCtx == nil { + t.Error("gotCtx is nil") + } + + gotData := dataFromContext(gotCtx) + gotOptions := optionsFromContext(gotCtx) + + if diff := cmp.Diff(gotData, tt.args.data, opts...); diff != "" { + t.Errorf("WithData() mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(gotOptions, tt.args.options, opts...); diff != "" { + t.Errorf("WithOptions() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_payloadFromContext(t *testing.T) { + t.Parallel() + + type args struct { + ctx context.Context + subject string + message string + data map[string]interface{} + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "Payload with only subject and message", + args: args{ + ctx: context.Background(), + subject: "test", + message: "test", + }, + want: []byte(`{"subject":"test","message":"test"}`), + }, + { + name: "Payload with subject, message and data", + args: args{ + ctx: context.Background(), + subject: "test", + message: "test", + data: map[string]interface{}{ + "title": "Test", + }, + }, + want: []byte(`{"subject":"test","message":"test","data":{"title":"Test"}}`), + }, + } + + opts := []cmp.Option{ + cmp.AllowUnexported(Service{}), + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.args.data != nil { + tt.args.ctx = WithData(tt.args.ctx, tt.args.data) + } + + got, err := payloadFromContext(tt.args.ctx, tt.args.subject, tt.args.message) + if (err != nil) != tt.wantErr { + t.Errorf("payloadFromContext() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want, opts...); diff != "" { + t.Errorf("payloadFromContext() mismatch (-want +got):\n%s", diff) + } + }) + } +}