nimbleghost 2 лет назад
Родитель
Сommit
a9fef387fa

+ 1 - 1
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 {

+ 30 - 6
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: <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 - 0
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...))
+}

+ 2 - 2
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                                                                                                                                                                                                  |
 

+ 1 - 1
docs/develop.md

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


BIN
docs/static/img/pwa-install.png


BIN
docs/static/img/pwa.png


BIN
docs/static/img/web-pin.png


BIN
docs/static/img/web-push-settings.png


BIN
docs/static/img/web-subscribe.png


+ 12 - 0
docs/subscribe/desktop.md

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

+ 44 - 11
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).
     <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>

+ 1 - 0
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":

+ 2 - 0
server/config.go

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

+ 16 - 15
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,25 +1042,28 @@ 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
 			}
-		}(i, xi)
-	}
 
-	ctx = log.Context{"topic": m.Topic, "message_id": m.ID, "failed_count": failedCount, "total_count": totalCount}
+			// 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")
 
-	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)
+				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)
 	}
 }
 

+ 1 - 1
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: ""

+ 24 - 2
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 {

+ 212 - 0
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)
+}

+ 1 - 1
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`;