Add web push tests

This commit is contained in:
nimbleghost 2023-05-29 17:57:21 +02:00
parent ff5c854192
commit a9fef387fa
20 changed files with 372 additions and 41 deletions

View File

@ -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 {

View File

@ -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: <filename>
web-push-email-address: <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
}

24
cmd/web_push_test.go Normal file
View File

@ -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...))
}

View File

@ -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 |

View File

@ -247,7 +247,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
#### With the dev servers
1. Get web push keys `go run main.go web-push-keys`
1. Get web push keys `go run main.go web-push generate-keys`
2. Run the server with web push enabled

BIN
docs/static/img/pwa-badge.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

BIN
docs/static/img/pwa-install.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

BIN
docs/static/img/pwa.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/static/img/web-push-settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 294 KiB

12
docs/subscribe/desktop.md Normal file
View File

@ -0,0 +1,12 @@
# Using the web app as an installed PWA
While ntfy doesn't have a built desktop app, it is built as a progressive web app and can be installed.
This is supported on Chrome and Edge on desktop, as well as Chrome on Android and Safari on iOS.
[caniuse reference](https://caniuse.com/web-app-manifest)
<div id="pwa-screenshots" class="screenshots">
<a href="../../static/img/pwa.png"><img src="../../static/img/pwa.png"/></a>
<a href="../../static/img/pwa-install.png"><img src="../../static/img/pwa-install.png"/></a>
<a href="../../static/img/pwa-badge.png"><img src="../../static/img/pwa-badge.png"/></a>
</div>

View File

@ -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).
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
</div>
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:
<figure markdown>
![pinned](../static/img/web-pin.png){ width=500 }
<figcaption>Pin web app to move it out of the way</figcaption>
</figure>
If topic reservations are enabled, you can claim ownership over topics and define access to it:
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>
You can set your default choice for new subscriptions (for example synced account subscriptions and the default toggle state)
in the settings page:
<div id="push-settings-screenshots" class="screenshots">
<a href="../../static/img/web-push-settings.png"><img src="../../static/img/web-push-settings.png"/></a>
</div>

View File

@ -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":

View File

@ -233,8 +233,10 @@ func NewConfig() *Config {
EnableReservations: false,
AccessControlAllowOrigin: "*",
Version: "",
WebPushEnabled: false,
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushSubscriptionsFile: "",
WebPushEmailAddress: "",
}
}

View File

@ -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) {

View File

@ -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: ""

View File

@ -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 {

View File

@ -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)
}

View File

@ -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`;