diff --git a/cmd/serve.go b/cmd/serve.go index a5105bcd..4ec94945 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -194,7 +194,7 @@ func execServe(c *cli.Context) error { if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { return errors.New("if set, FCM key file must exist") } else if webPushEnabled && (webPushPrivateKey == "" || webPushPublicKey == "" || webPushSubscriptionsFile == "" || webPushEmailAddress == "" || baseURL == "") { - return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-subscriptions-file, web-push-email-address, and base-url should be set. run 'ntfy web-push-keys' to generate keys") + return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-subscriptions-file, web-push-email-address, and base-url should be set. run 'ntfy web-push generate-keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { diff --git a/cmd/web_push.go b/cmd/web_push.go index becaffd7..8b09762c 100644 --- a/cmd/web_push.go +++ b/cmd/web_push.go @@ -14,11 +14,20 @@ func init() { } var cmdWebPush = &cli.Command{ - Name: "web-push-keys", - Usage: "Generate web push VAPID keys", - UsageText: "ntfy web-push-keys", + Name: "web-push", + Usage: "Generate keys, in the future manage web push subscriptions", + UsageText: "ntfy web-push [generate-keys]", Category: categoryServer, - Action: generateWebPushKeys, + + Subcommands: []*cli.Command{ + { + Action: generateWebPushKeys, + Name: "generate-keys", + Usage: "Generate VAPID keys to enable browser background push notifications", + UsageText: "ntfy web-push generate-keys", + Category: categoryServer, + }, + }, } func generateWebPushKeys(c *cli.Context) error { @@ -27,13 +36,28 @@ func generateWebPushKeys(c *cli.Context) error { return err } - fmt.Fprintf(c.App.ErrWriter, `Add the following lines to your config file: + fmt.Fprintf(c.App.ErrWriter, `Keys generated. + +VAPID Public Key: +%s + +VAPID Private Key: +%s + +--- + +Add the following lines to your config file: + web-push-enabled: true web-push-public-key: %s web-push-private-key: %s web-push-subscriptions-file: web-push-email-address: -`, publicKey, privateKey) + +Look at the docs for other methods (e.g. command line flags & environment variables). + +You will also need to set a base-url. +`, publicKey, privateKey, publicKey, privateKey) return nil } diff --git a/cmd/web_push_test.go b/cmd/web_push_test.go new file mode 100644 index 00000000..3241ea43 --- /dev/null +++ b/cmd/web_push_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "heckel.io/ntfy/server" +) + +func TestCLI_WebPush_GenerateKeys(t *testing.T) { + app, _, _, stderr := newTestApp() + require.Nil(t, runWebPushCommand(app, server.NewConfig(), "generate-keys")) + require.Contains(t, stderr.String(), "Keys generated.") +} + +func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error { + webPushArgs := []string{ + "ntfy", + "--log-level=ERROR", + "web-push", + } + return app.Run(append(webPushArgs, args...)) +} diff --git a/docs/config.md b/docs/config.md index 774f9b2f..3aeab614 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1286,8 +1286,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | | `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | | `web-push-enabled` | `NTFY_WEB_PUSH_ENABLED` | *boolean* (`true` or `false`) | - | Web Push: Enable/disable (requires private and public key below). | -| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy web-push-keys` to generate | -| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push-keys` to generate | +| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy web-push generate-keys` to generate | +| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push generate-keys` to generate | | `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file | | `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | diff --git a/docs/develop.md b/docs/develop.md index 6be65abd..09215d9a 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -247,7 +247,7 @@ Reference: + + + + diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md index 5c2672f0..a563f8b0 100644 --- a/docs/subscribe/web.md +++ b/docs/subscribe/web.md @@ -1,7 +1,41 @@ # Subscribe from the Web UI -You can use the Web UI to subscribe to topics as well. If you do, and you keep the website open, **notifications will -pop up as desktop notifications**. Simply type in the topic name and click the *Subscribe* button. The browser will -keep a connection open and listen for incoming notifications. + +You can use the Web UI to subscribe to topics as well. Simply type in the topic name and click the *Subscribe* button. + +While subscribing, you have the option to enable desktop notifications, as well as background notifications. When you +enable them for the first time, you will be prompted to allow notifications on your browser. + +- **Sound only** + + If you don't enable browser notifications, a sound will play when a new notification comes in, and the tab title + will show the number of new notifications. + +- **Browser Notifications** + + This requires an active ntfy tab to be open to receive notifications. These are typically instantaneous, and will + appear as a system notification. If you don't see these, check that your browser is allowed to show notifications + (for example in System Settings on macOS). + + If you don't want to enable background notifications, pinning the ntfy tab on your browser is a good solution to leave + it running. + +- **Background Notifications** + + This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active ntfy tab open, but in some + cases you may need to keep your browser open. + + + | Browser | Platform | Browser Running | Browser Not Running | Notes | + | ------- | -------- | --------------- | ------------------- | ------------------------------------------------------- | + | Chrome | Desktop | ✅ | ❌ | | + | Firefox | Desktop | ✅ | ❌ | | + | Edge | Desktop | ✅ | ❌ | | + | Opera | Desktop | ✅ | ❌ | | + | Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura | + | Chrome | Android | ✅ | ✅ | | + | Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen | + + (Browsers below 1% usage not shown, look at the Push API link for more info) To learn how to send messages, check out the [publishing page](../publish.md). @@ -11,17 +45,16 @@ To learn how to send messages, check out the [publishing page](../publish.md). -To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend, -is to pin the tab so that it's always open, but sort of out of the way: - -
- ![pinned](../static/img/web-pin.png){ width=500 } -
Pin web app to move it out of the way
-
- If topic reservations are enabled, you can claim ownership over topics and define access to it:
+ +You can set your default choice for new subscriptions (for example synced account subscriptions and the default toggle state) +in the settings page: + +
+ +
diff --git a/mkdocs.yml b/mkdocs.yml index 4a7db366..76d39299 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - "Subscribing": - "From your phone": subscribe/phone.md - "From the Web app": subscribe/web.md + - "From the Desktop": subscribe/desktop.md - "From the CLI": subscribe/cli.md - "Using the API": subscribe/api.md - "Self-hosting": diff --git a/server/config.go b/server/config.go index f7c1813d..d5c87672 100644 --- a/server/config.go +++ b/server/config.go @@ -233,8 +233,10 @@ func NewConfig() *Config { EnableReservations: false, AccessControlAllowOrigin: "*", Version: "", + WebPushEnabled: false, WebPushPrivateKey: "", WebPushPublicKey: "", WebPushSubscriptionsFile: "", + WebPushEmailAddress: "", } } diff --git a/server/server.go b/server/server.go index cba74280..72e9c19a 100644 --- a/server/server.go +++ b/server/server.go @@ -77,7 +77,7 @@ var ( rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) - webPushPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push$`) + webPushSubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/subscribe$`) webPushUnsubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/unsubscribe$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) @@ -535,7 +535,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) - } else if r.Method == http.MethodPost && webPushPathRegex.MatchString(r.URL.Path) { + } else if r.Method == http.MethodPost && webPushSubscribePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicRead(s.ensureWebPushEnabled(s.handleTopicWebPushSubscribe)))(w, r, v) } else if r.Method == http.MethodPost && webPushUnsubscribePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicRead(s.ensureWebPushEnabled(s.handleTopicWebPushUnsubscribe)))(w, r, v) @@ -985,7 +985,6 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { return } - failedCount := 0 totalCount := len(subscriptions) wg := &sync.WaitGroup{} @@ -1029,12 +1028,11 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { jsonPayload, err := json.Marshal(payload) if err != nil { - failedCount++ logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message") return } - _, err = webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{ + resp, err := webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{ Subscriber: s.config.WebPushEmailAddress, VAPIDPublicKey: s.config.WebPushPublicKey, VAPIDPrivateKey: s.config.WebPushPrivateKey, @@ -1044,26 +1042,29 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { }) if err != nil { - failedCount++ logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message") - // probably need to handle different codes differently, - // but for now just expire the subscription on any error err = s.webPushSubscriptionStore.ExpireWebPushEndpoint(sub.BrowserSubscription.Endpoint) if err != nil { logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription") } + + return + } + + // May want to handle at least 429 differently, but for now treat all errors the same + if !(200 <= resp.StatusCode && resp.StatusCode <= 299) { + logvm(v, m).Fields(ctx).Field("response", resp).Debug("Unable to publish web push message") + + err = s.webPushSubscriptionStore.ExpireWebPushEndpoint(sub.BrowserSubscription.Endpoint) + if err != nil { + logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription") + } + + return } }(i, xi) } - - ctx = log.Context{"topic": m.Topic, "message_id": m.ID, "failed_count": failedCount, "total_count": totalCount} - - if failedCount > 0 { - logvm(v, m).Fields(ctx).Warn("Unable to publish web push messages to %d of %d endpoints", failedCount, totalCount) - } else { - logvm(v, m).Fields(ctx).Debug("Published %d web push messages successfully", totalCount) - } } func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { diff --git a/server/server.yml b/server/server.yml index ecb89994..e747e264 100644 --- a/server/server.yml +++ b/server/server.yml @@ -40,7 +40,7 @@ # Enable web push # -# Run ntfy web-push-keys to generate the keys +# Run ntfy web-push generate-keys to generate the keys # # web-push-enabled: true # web-push-public-key: "" diff --git a/server/server_test.go b/server/server_test.go index b9f2912d..f264d096 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/SherClockHolmes/webpush-go" "github.com/stretchr/testify/require" "heckel.io/ntfy/log" "heckel.io/ntfy/util" @@ -2604,14 +2605,35 @@ func newTestConfig(t *testing.T) *Config { return conf } -func newTestConfigWithAuthFile(t *testing.T) *Config { - conf := newTestConfig(t) +func configureAuth(t *testing.T, conf *Config) *Config { conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot return conf } +func newTestConfigWithAuthFile(t *testing.T) *Config { + conf := newTestConfig(t) + conf = configureAuth(t, conf) + return conf +} + +func newTestConfigWithWebPush(t *testing.T) *Config { + conf := newTestConfig(t) + + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + t.Fatal(err) + } + + conf.WebPushEnabled = true + conf.WebPushSubscriptionsFile = filepath.Join(t.TempDir(), "subscriptions.db") + conf.WebPushEmailAddress = "testing@example.com" + conf.WebPushPrivateKey = privateKey + conf.WebPushPublicKey = publicKey + return conf +} + func newTestServer(t *testing.T, config *Config) *Server { server, err := New(config) if err != nil { diff --git a/server/server_web_push_test.go b/server/server_web_push_test.go new file mode 100644 index 00000000..80f37e3e --- /dev/null +++ b/server/server_web_push_test.go @@ -0,0 +1,212 @@ +package server + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/SherClockHolmes/webpush-go" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/user" + "heckel.io/ntfy/util" +) + +var ( + webPushSubscribePayloadExample = `{ + "browser_subscription":{ + "endpoint": "https://example.com/webpush", + "keys": { + "p256dh": "p256dh-key", + "auth": "auth-key" + } + } + }` +) + +func TestServer_WebPush_GetConfig(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "GET", "/v1/web-push-config", "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, fmt.Sprintf(`{"public_key":"%s"}`, s.config.WebPushPublicKey)+"\n", response.Body.String()) +} + +func TestServer_WebPush_TopicSubscribe(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + subs, err := s.webPushSubscriptionStore.GetSubscriptionsForTopic("test-topic") + if err != nil { + t.Fatal(err) + } + + require.Len(t, subs, 1) + require.Equal(t, subs[0].BrowserSubscription.Endpoint, "https://example.com/webpush") + require.Equal(t, subs[0].BrowserSubscription.Keys.P256dh, "p256dh-key") + require.Equal(t, subs[0].BrowserSubscription.Keys.Auth, "auth-key") + require.Equal(t, subs[0].Username, "") +} + +func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) + + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + + response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + subs, err := s.webPushSubscriptionStore.GetSubscriptionsForTopic("test-topic") + if err != nil { + t.Fatal(err) + } + + require.Len(t, subs, 1) + require.Equal(t, subs[0].Username, "ben") +} + +func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) + + response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil) + require.Equal(t, 403, response.Code) + + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 1) + + unsubscribe := `{"endpoint":"https://example.com/webpush"}` + response = request(t, s, "POST", "/test-topic/web-push/unsubscribe", unsubscribe, nil) + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { + config := configureAuth(t, newTestConfigWithWebPush(t)) + config.AuthDefault = user.PermissionDenyAll + s := newTestServer(t, config) + + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) + require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) + + response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + + require.Equal(t, 200, response.Code) + require.Equal(t, `{"success":true}`+"\n", response.Body.String()) + + requireSubscriptionCount(t, s, "test-topic", 1) + + request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + // should've been deleted with the account + requireSubscriptionCount(t, s, "test-topic", 0) +} + +func TestServer_WebPush_Publish(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/push-receive", r.URL.Path) + require.Equal(t, "high", r.Header.Get("Urgency")) + require.Equal(t, "", r.Header.Get("Topic")) + received.Store(true) + })) + defer upstreamServer.Close() + + addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") + + request(t, s, "PUT", "/test-topic", "web push test", nil) + + waitFor(t, func() bool { + return received.Load() + }) +} + +func TestServer_WebPush_PublishExpire(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + // Gone + w.WriteHeader(410) + w.Write([]byte(``)) + received.Store(true) + })) + defer upstreamServer.Close() + + addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") + addSubscription(t, s, "test-topic-abc", upstreamServer.URL+"/push-receive") + + requireSubscriptionCount(t, s, "test-topic", 1) + requireSubscriptionCount(t, s, "test-topic-abc", 1) + + request(t, s, "PUT", "/test-topic", "web push test", nil) + + waitFor(t, func() bool { + return received.Load() + }) + + // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint + + requireSubscriptionCount(t, s, "test-topic", 0) + requireSubscriptionCount(t, s, "test-topic-abc", 0) +} + +func addSubscription(t *testing.T, s *Server, topic string, url string) { + err := s.webPushSubscriptionStore.AddSubscription("test-topic", "", webPushSubscribePayload{ + BrowserSubscription: webpush.Subscription{ + Endpoint: url, + Keys: webpush.Keys{ + // connected to a local test VAPID key, not a leak! + Auth: "kSC3T8aN1JCQxxPdrFLrZg", + P256dh: "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", + }, + }, + }) + if err != nil { + t.Fatal(err) + } +} + +func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) { + subs, err := s.webPushSubscriptionStore.GetSubscriptionsForTopic("test-topic") + if err != nil { + t.Fatal(err) + } + + require.Len(t, subs, expectedLength) +} diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 0f879373..bf09bef3 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -20,7 +20,7 @@ export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/jso export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; -export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push`; +export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/subscribe`; export const topicUrlWebPushUnsubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/unsubscribe`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const webPushConfigUrl = (baseUrl) => `${baseUrl}/v1/web-push-config`;