Jelajahi Sumber

Add PWA, service worker and Web Push

- Use new notification request/opt-in flow for push
- Implement unsubscribing
- Implement muting
- Implement emojis in title
- Add iOS specific PWA warning
- Don’t use websockets when web push is enabled
- Fix duplicate notifications
- Implement default web push setting
- Implement changing subscription type
- Implement web push subscription refresh
- Implement web push notification click
nimbleghost 2 tahun lalu
induk
melakukan
ff5c854192
53 mengubah file dengan 3039 tambahan dan 335 penghapusan
  1. 1 0
      .gitignore
  2. 17 0
      cmd/serve.go
  3. 39 0
      cmd/web_push.go
  4. 24 7
      docs/config.md
  5. 62 1
      docs/develop.md
  6. 2 0
      go.mod
  7. 5 0
      go.sum
  8. 10 1
      server/config.go
  9. 1 0
      server/errors.go
  10. 241 49
      server/server.go
  11. 10 0
      server/server.yml
  12. 7 0
      server/server_account.go
  13. 9 0
      server/server_middleware.go
  14. 13 0
      server/server_test.go
  15. 0 24
      server/smtp_sender.go
  16. 24 0
      server/types.go
  17. 24 0
      server/util.go
  18. 132 0
      server/web_push.go
  19. 2 1
      web/.eslintrc
  20. 7 0
      web/index.html
  21. 1328 88
      web/package-lock.json
  22. 2 1
      web/package.json
  23. 1 1
      web/public/config.js
  24. TEMPAT SAMPAH
      web/public/static/images/apple-touch-icon.png
  25. 20 0
      web/public/static/images/mask-icon.svg
  26. TEMPAT SAMPAH
      web/public/static/images/pwa-192x192.png
  27. TEMPAT SAMPAH
      web/public/static/images/pwa-512x512.png
  28. 15 3
      web/public/static/langs/en.json
  29. 111 0
      web/public/sw.js
  30. 4 0
      web/src/app/AccountApi.js
  31. 59 0
      web/src/app/Api.js
  32. 12 5
      web/src/app/ConnectionManager.js
  33. 75 27
      web/src/app/Notifier.js
  34. 6 3
      web/src/app/Poller.js
  35. 21 9
      web/src/app/Prefs.js
  36. 4 0
      web/src/app/Pruner.js
  37. 11 1
      web/src/app/Session.js
  38. 44 0
      web/src/app/SessionReplica.js
  39. 163 35
      web/src/app/SubscriptionManager.js
  40. 10 7
      web/src/app/UserManager.js
  41. 46 0
      web/src/app/WebPushWorker.js
  42. 0 21
      web/src/app/db.js
  43. 34 0
      web/src/app/getDb.js
  44. 17 1
      web/src/app/utils.js
  45. 5 2
      web/src/components/Account.jsx
  46. 4 2
      web/src/components/ActionBar.jsx
  47. 4 0
      web/src/components/App.jsx
  48. 28 16
      web/src/components/Navigation.jsx
  49. 32 0
      web/src/components/Preferences.jsx
  50. 111 10
      web/src/components/SubscribeDialog.jsx
  51. 156 12
      web/src/components/SubscriptionPopup.jsx
  52. 26 7
      web/src/components/hooks.js
  53. 60 1
      web/vite.config.js

+ 1 - 0
.gitignore

@@ -13,3 +13,4 @@ secrets/
 node_modules/
 .DS_Store
 __pycache__
+web/dev-dist/

+ 17 - 0
cmd/serve.go

@@ -94,6 +94,11 @@ var flagsServe = append(
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
+	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "web-push-enabled", Aliases: []string{"web_push_enabled"}, EnvVars: []string{"NTFY_WEB_PUSH_ENABLED"}, Usage: "enable web push (requires public and private key)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-subscriptions-file", Aliases: []string{"web_push_subscriptions_file"}, EnvVars: []string{"NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE"}, Usage: "file used to store web push subscriptions"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
 )
 
 var cmdServe = &cli.Command{
@@ -129,6 +134,11 @@ func execServe(c *cli.Context) error {
 	keyFile := c.String("key-file")
 	certFile := c.String("cert-file")
 	firebaseKeyFile := c.String("firebase-key-file")
+	webPushEnabled := c.Bool("web-push-enabled")
+	webPushPrivateKey := c.String("web-push-private-key")
+	webPushPublicKey := c.String("web-push-public-key")
+	webPushSubscriptionsFile := c.String("web-push-subscriptions-file")
+	webPushEmailAddress := c.String("web-push-email-address")
 	cacheFile := c.String("cache-file")
 	cacheDuration := c.Duration("cache-duration")
 	cacheStartupQueries := c.String("cache-startup-queries")
@@ -183,6 +193,8 @@ func execServe(c *cli.Context) error {
 	// Check values
 	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")
 	} else if keepaliveInterval < 5*time.Second {
 		return errors.New("keepalive interval cannot be lower than five seconds")
 	} else if managerInterval < 5*time.Second {
@@ -347,6 +359,11 @@ func execServe(c *cli.Context) error {
 	conf.MetricsListenHTTP = metricsListenHTTP
 	conf.ProfileListenHTTP = profileListenHTTP
 	conf.Version = c.App.Version
+	conf.WebPushEnabled = webPushEnabled
+	conf.WebPushPrivateKey = webPushPrivateKey
+	conf.WebPushPublicKey = webPushPublicKey
+	conf.WebPushSubscriptionsFile = webPushSubscriptionsFile
+	conf.WebPushEmailAddress = webPushEmailAddress
 
 	// Set up hot-reloading of config
 	go sigHandlerConfigReload(config)

+ 39 - 0
cmd/web_push.go

@@ -0,0 +1,39 @@
+//go:build !noserver
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/SherClockHolmes/webpush-go"
+	"github.com/urfave/cli/v2"
+)
+
+func init() {
+	commands = append(commands, cmdWebPush)
+}
+
+var cmdWebPush = &cli.Command{
+	Name:      "web-push-keys",
+	Usage:     "Generate web push VAPID keys",
+	UsageText: "ntfy web-push-keys",
+	Category:  categoryServer,
+	Action:    generateWebPushKeys,
+}
+
+func generateWebPushKeys(c *cli.Context) error {
+	privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintf(c.App.ErrWriter, `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)
+
+	return nil
+}

+ 24 - 7
docs/config.md

@@ -1285,13 +1285,17 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `stripe-secret-key`                        | `NTFY_STRIPE_SECRET_KEY`                        | *string*                                            | -                 | Payments: Key used for the Stripe API communication, this enables payments                                                                                                                                                      |
 | `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-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                                                                                                                                                                                                  |
 
 The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.   
 The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
 
 ## Command line options
 ```
-$ ntfy serve --help
 NAME:
    ntfy serve - Run the ntfy server
 
@@ -1321,8 +1325,8 @@ OPTIONS:
    --log-file value, --log_file value                                                                                     set log file, default is STDOUT [$NTFY_LOG_FILE]
    --config value, -c value                                                                                               config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
    --base-url value, --base_url value, -B value                                                                           externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
-   --listen-http value, --listen_http value, -l value                                                                     ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
-   --listen-https value, --listen_https value, -L value                                                                   ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
+   --listen-http value, --listen_http value, -l value                                                                     ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
+   --listen-https value, --listen_https value, -L value                                                                   ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
    --listen-unix value, --listen_unix value, -U value                                                                     listen on unix socket path [$NTFY_LISTEN_UNIX]
    --listen-unix-mode value, --listen_unix_mode value                                                                     file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
    --key-file value, --key_file value, -K value                                                                           private key file, if listen-https is set [$NTFY_KEY_FILE]
@@ -1343,11 +1347,12 @@ OPTIONS:
    --keepalive-interval value, --keepalive_interval value, -k value                                                       interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
    --manager-interval value, --manager_interval value, -m value                                                           interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
    --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ]          topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
-   --web-root value, --web_root value                                                                                     sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
+   --web-root value, --web_root value                                                                                     sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
    --enable-signup, --enable_signup                                                                                       allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
    --enable-login, --enable_login                                                                                         allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
    --enable-reservations, --enable_reservations                                                                           allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
    --upstream-base-url value, --upstream_base_url value                                                                   forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
+   --upstream-access-token value, --upstream_access_token value                                                           access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN]
    --smtp-sender-addr value, --smtp_sender_addr value                                                                     SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
    --smtp-sender-user value, --smtp_sender_user value                                                                     SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
    --smtp-sender-pass value, --smtp_sender_pass value                                                                     SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
@@ -1355,6 +1360,10 @@ OPTIONS:
    --smtp-server-listen value, --smtp_server_listen value                                                                 SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
    --smtp-server-domain value, --smtp_server_domain value                                                                 SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
    --smtp-server-addr-prefix value, --smtp_server_addr_prefix value                                                       SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
+   --twilio-account value, --twilio_account value                                                                         Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT]
+   --twilio-auth-token value, --twilio_auth_token value                                                                   Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
+   --twilio-phone-number value, --twilio_phone_number value                                                               Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
+   --twilio-verify-service value, --twilio_verify_service value                                                           Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
    --global-topic-limit value, --global_topic_limit value, -T value                                                       total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
    --visitor-subscription-limit value, --visitor_subscription_limit value                                                 number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
    --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value                               total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
@@ -1365,10 +1374,18 @@ OPTIONS:
    --visitor-message-daily-limit value, --visitor_message_daily_limit value                                               max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
    --visitor-email-limit-burst value, --visitor_email_limit_burst value                                                   initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
    --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                                           interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
+   --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting                                                 enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
    --behind-proxy, --behind_proxy, -P                                                                                     if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
    --stripe-secret-key value, --stripe_secret_key value                                                                   key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
    --stripe-webhook-key value, --stripe_webhook_key value                                                                 key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
-   --billing-contact value, --billing_contact value                                                                       e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]   
-   --help, -h                                                                                                             show help (default: false)
+   --billing-contact value, --billing_contact value                                                                       e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
+   --enable-metrics, --enable_metrics                                                                                     if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS]
+   --metrics-listen-http value, --metrics_listen_http value                                                               ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP]
+   --profile-listen-http value, --profile_listen_http value                                                               ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP]
+   --web-push-enabled, --web_push_enabled                                                                                 enable web push (requires public and private key) (default: false) [$NTFY_WEB_PUSH_ENABLED]
+   --web-push-public-key value, --web_push_public_key value                                                               public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY]
+   --web-push-private-key value, --web_push_private_key value                                                             private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
+   --web-push-subscriptions-file value, --web_push_subscriptions_file value                                               file used to store web push subscriptions [$NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE]
+   --web-push-email-address value, --web_push_email_address value                                                         e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
+   --help, -h                                                                                                             show help
 ```
-

+ 62 - 1
docs/develop.md

@@ -16,7 +16,7 @@ server consists of three components:
 * **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
   which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
   build the docs.
-* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
+* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/)
   to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`) 
   and install all the 100,000 dependencies (*sigh*).
 
@@ -241,6 +241,67 @@ $ cd web
 $ npm start
 ```
 
+### Testing Web Push locally
+
+Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-service-workers-via-http>
+
+#### With the dev servers
+
+1. Get web push keys `go run main.go web-push-keys`
+
+2. Run the server with web push enabled
+
+    ```sh
+    go run main.go \
+      --log-level debug \
+      serve \
+        --web-push-enabled \
+        --web-push-public-key KEY \
+        --web-push-private-key KEY \
+        --web-push-subscriptions-file=/tmp/subscriptions.db
+    ```
+
+3. In `web/public/config.js` set `base_url` to `http://localhost`. This is required as web push can only be used
+   with the server matching the `base_url`
+
+4. Run `ENABLE_DEV_PWA=1 npm run start` - this enables the dev service worker
+
+5. Set your browser to allow testing service workers insecurely:
+
+   - Chrome:
+
+      Open Chrome with special flags allowing insecure localhost service worker testing (regularly dismissing SSL warnings is not enough)
+
+      ```sh
+      # for example, macOS
+      /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
+        --user-data-dir=/tmp/foo \
+        --unsafely-treat-insecure-origin-as-secure=http://localhost:3000,http://localhost
+      ```
+
+  - Firefox:
+  
+      See here: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
+
+      > Note: On Firefox, for testing you can run service workers over HTTP (insecurely); simply check the Enable Service Workers over HTTP (when toolbox is open) option in the Firefox Devtools options/gear menu
+
+  - Safari, iOS:
+
+      There doesn't seem to be a good way to do this currently. The only way is to serve a valid HTTPS certificate.
+
+      This is beyond the scope of this guide, but you can try `mkcert`, a number of reverse proxies such as Traefik and Caddy,
+      or tunneling software such as [Cloudflare Tunnels][cloudflare_tunnels] or ngrok.
+
+[cloudflare_tunnels]: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/do-more-with-tunnels/trycloudflare/
+
+6. Open <http://localhost:3000/>
+#### With a built package
+
+1. Run `make web-build`
+
+2. Follow steps 1, 2, 4 and 5 from "With the dev servers"
+
+3. Open <http://localhost/>
 ### Build the docs
 The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the 
 documentation. As long as you have `mkdocs` installed (see above), this should work fine:

+ 2 - 0
go.mod

@@ -39,10 +39,12 @@ require (
 	cloud.google.com/go/longrunning v0.5.0 // indirect
 	github.com/AlekSi/pointer v1.2.0 // indirect
 	github.com/MicahParks/keyfunc v1.9.0 // indirect
+	github.com/SherClockHolmes/webpush-go v1.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
+	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.3 // indirect

+ 5 - 0
go.sum

@@ -23,6 +23,8 @@ github.com/BurntSushi/toml v1.3.1 h1:rHnDkSK+/g6DlREUK73PkmIs60pqrnuduK+JmP++JmU
 github.com/BurntSushi/toml v1.3.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
 github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
+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/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -57,6 +59,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
 github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+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/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
 github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -149,6 +153,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

+ 10 - 1
server/config.go

@@ -1,10 +1,11 @@
 package server
 
 import (
-	"heckel.io/ntfy/user"
 	"io/fs"
 	"net/netip"
 	"time"
+
+	"heckel.io/ntfy/user"
 )
 
 // Defines default config settings (excluding limits, see below)
@@ -146,6 +147,11 @@ type Config struct {
 	EnableMetrics                        bool
 	AccessControlAllowOrigin             string // CORS header field to restrict access from web clients
 	Version                              string // injected by App
+	WebPushEnabled                       bool
+	WebPushPrivateKey                    string
+	WebPushPublicKey                     string
+	WebPushSubscriptionsFile             string
+	WebPushEmailAddress                  string
 }
 
 // NewConfig instantiates a default new server config
@@ -227,5 +233,8 @@ func NewConfig() *Config {
 		EnableReservations:                   false,
 		AccessControlAllowOrigin:             "*",
 		Version:                              "",
+		WebPushPrivateKey:                    "",
+		WebPushPublicKey:                     "",
+		WebPushSubscriptionsFile:             "",
 	}
 }

+ 1 - 0
server/errors.go

@@ -114,6 +114,7 @@ var (
 	errHTTPBadRequestAnonymousCallsNotAllowed        = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
 	errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
 	errHTTPBadRequestDelayNoCall                     = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
+	errHTTPBadRequestWebPushSubscriptionInvalid      = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

+ 241 - 49
server/server.go

@@ -9,13 +9,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/emersion/go-smtp"
-	"github.com/gorilla/websocket"
-	"github.com/prometheus/client_golang/prometheus/promhttp"
-	"golang.org/x/sync/errgroup"
-	"heckel.io/ntfy/log"
-	"heckel.io/ntfy/user"
-	"heckel.io/ntfy/util"
 	"io"
 	"net"
 	"net/http"
@@ -32,32 +25,43 @@ import (
 	"sync"
 	"time"
 	"unicode/utf8"
+
+	"github.com/emersion/go-smtp"
+	"github.com/gorilla/websocket"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+	"golang.org/x/sync/errgroup"
+	"heckel.io/ntfy/log"
+	"heckel.io/ntfy/user"
+	"heckel.io/ntfy/util"
+
+	"github.com/SherClockHolmes/webpush-go"
 )
 
 // Server is the main server, providing the UI and API for ntfy
 type Server struct {
-	config            *Config
-	httpServer        *http.Server
-	httpsServer       *http.Server
-	httpMetricsServer *http.Server
-	httpProfileServer *http.Server
-	unixListener      net.Listener
-	smtpServer        *smtp.Server
-	smtpServerBackend *smtpBackend
-	smtpSender        mailer
-	topics            map[string]*topic
-	visitors          map[string]*visitor // ip:<ip> or user:<user>
-	firebaseClient    *firebaseClient
-	messages          int64                               // Total number of messages (persisted if messageCache enabled)
-	messagesHistory   []int64                             // Last n values of the messages counter, used to determine rate
-	userManager       *user.Manager                       // Might be nil!
-	messageCache      *messageCache                       // Database that stores the messages
-	fileCache         *fileCache                          // File system based cache that stores attachments
-	stripe            stripeAPI                           // Stripe API, can be replaced with a mock
-	priceCache        *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
-	metricsHandler    http.Handler                        // Handles /metrics if enable-metrics set, and listen-metrics-http not set
-	closeChan         chan bool
-	mu                sync.RWMutex
+	config                   *Config
+	httpServer               *http.Server
+	httpsServer              *http.Server
+	httpMetricsServer        *http.Server
+	httpProfileServer        *http.Server
+	unixListener             net.Listener
+	smtpServer               *smtp.Server
+	smtpServerBackend        *smtpBackend
+	smtpSender               mailer
+	topics                   map[string]*topic
+	visitors                 map[string]*visitor // ip:<ip> or user:<user>
+	firebaseClient           *firebaseClient
+	messages                 int64                               // Total number of messages (persisted if messageCache enabled)
+	messagesHistory          []int64                             // Last n values of the messages counter, used to determine rate
+	userManager              *user.Manager                       // Might be nil!
+	messageCache             *messageCache                       // Database that stores the messages
+	webPushSubscriptionStore *webPushSubscriptionStore           // Database that stores web push subscriptions
+	fileCache                *fileCache                          // File system based cache that stores attachments
+	stripe                   stripeAPI                           // Stripe API, can be replaced with a mock
+	priceCache               *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
+	metricsHandler           http.Handler                        // Handles /metrics if enable-metrics set, and listen-metrics-http not set
+	closeChan                chan bool
+	mu                       sync.RWMutex
 }
 
 // handleFunc extends the normal http.HandlerFunc to be able to easily return errors
@@ -65,17 +69,21 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
 
 var (
 	// If changed, don't forget to update Android App and auth_sqlite.go
-	topicRegex             = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)               // No /!
-	topicPathRegex         = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`)              // Regex must match JS & Android app!
-	externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
-	jsonPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
-	ssePathRegex           = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
-	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$`)
-	publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
+	topicRegex                  = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)               // No /!
+	topicPathRegex              = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`)              // Regex must match JS & Android app!
+	externalTopicPathRegex      = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
+	jsonPathRegex               = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
+	ssePathRegex                = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
+	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$`)
+	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)$`)
 
 	webConfigPath                                        = "/config.js"
+	webManifestPath                                      = "/manifest.webmanifest"
+	webServiceWorkerPath                                 = "/sw.js"
 	accountPath                                          = "/account"
 	matrixPushPath                                       = "/_matrix/push/v1/notify"
 	metricsPath                                          = "/metrics"
@@ -98,6 +106,7 @@ var (
 	apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
 	apiAccountBillingSubscriptionCheckoutSuccessRegex    = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
 	apiAccountReservationSingleRegex                     = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
+	apiWebPushConfig                                     = "/v1/web-push-config"
 	staticRegex                                          = regexp.MustCompile(`^/static/.+`)
 	docsRegex                                            = regexp.MustCompile(`^/docs(|/.*)$`)
 	fileRegex                                            = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -151,6 +160,10 @@ func New(conf *Config) (*Server, error) {
 	if err != nil {
 		return nil, err
 	}
+	webPushSubscriptionStore, err := createWebPushSubscriptionStore(conf)
+	if err != nil {
+		return nil, err
+	}
 	topics, err := messageCache.Topics()
 	if err != nil {
 		return nil, err
@@ -188,17 +201,18 @@ func New(conf *Config) (*Server, error) {
 		firebaseClient = newFirebaseClient(sender, auther)
 	}
 	s := &Server{
-		config:          conf,
-		messageCache:    messageCache,
-		fileCache:       fileCache,
-		firebaseClient:  firebaseClient,
-		smtpSender:      mailer,
-		topics:          topics,
-		userManager:     userManager,
-		messages:        messages,
-		messagesHistory: []int64{messages},
-		visitors:        make(map[string]*visitor),
-		stripe:          stripe,
+		config:                   conf,
+		messageCache:             messageCache,
+		webPushSubscriptionStore: webPushSubscriptionStore,
+		fileCache:                fileCache,
+		firebaseClient:           firebaseClient,
+		smtpSender:               mailer,
+		topics:                   topics,
+		userManager:              userManager,
+		messages:                 messages,
+		messagesHistory:          []int64{messages},
+		visitors:                 make(map[string]*visitor),
+		stripe:                   stripe,
 	}
 	s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
 	return s, nil
@@ -213,6 +227,14 @@ func createMessageCache(conf *Config) (*messageCache, error) {
 	return newMemCache()
 }
 
+func createWebPushSubscriptionStore(conf *Config) (*webPushSubscriptionStore, error) {
+	if !conf.WebPushEnabled {
+		return nil, nil
+	}
+
+	return newWebPushSubscriptionStore(conf.WebPushSubscriptionsFile)
+}
+
 // Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
 // a manager go routine to print stats and prune messages.
 func (s *Server) Run() error {
@@ -342,6 +364,9 @@ func (s *Server) closeDatabases() {
 		s.userManager.Close()
 	}
 	s.messageCache.Close()
+	if s.webPushSubscriptionStore != nil {
+		s.webPushSubscriptionStore.Close()
+	}
 }
 
 // handle is the main entry point for all HTTP requests
@@ -416,6 +441,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleHealth(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
+		return s.ensureWebEnabled(s.handleWebManifest)(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == webServiceWorkerPath {
+		return s.ensureWebEnabled(s.handleStatic)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
 		return s.ensureAdmin(s.handleUsersGet)(w, r, v)
 	} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
@@ -474,6 +503,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleStats(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
 		return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == apiWebPushConfig {
+		return s.ensureWebPushEnabled(s.handleAPIWebPushConfig)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
 		return s.handleMatrixDiscovery(w)
 	} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
@@ -504,6 +535,10 @@ 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) {
+		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)
 	} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
 		return s.ensureWebEnabled(s.handleTopic)(w, r, v)
 	}
@@ -535,6 +570,63 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
 	return s.writeJSON(w, newSuccessResponse())
 }
 
+func (s *Server) handleAPIWebPushConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+	response := &apiWebPushConfigResponse{
+		PublicKey: s.config.WebPushPublicKey,
+	}
+
+	return s.writeJSON(w, response)
+}
+
+func (s *Server) handleTopicWebPushSubscribe(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	var username string
+	u := v.User()
+	if u != nil {
+		username = u.Name
+	}
+
+	var sub webPushSubscribePayload
+	err := json.NewDecoder(r.Body).Decode(&sub)
+
+	if err != nil || sub.BrowserSubscription.Endpoint == "" || sub.BrowserSubscription.Keys.P256dh == "" || sub.BrowserSubscription.Keys.Auth == "" {
+		return errHTTPBadRequestWebPushSubscriptionInvalid
+	}
+
+	topic, err := fromContext[*topic](r, contextTopic)
+	if err != nil {
+		return err
+	}
+
+	err = s.webPushSubscriptionStore.AddSubscription(topic.ID, username, sub)
+	if err != nil {
+		return err
+	}
+
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleTopicWebPushUnsubscribe(w http.ResponseWriter, r *http.Request, _ *visitor) error {
+	var payload webPushUnsubscribePayload
+
+	err := json.NewDecoder(r.Body).Decode(&payload)
+
+	if err != nil {
+		return errHTTPBadRequestWebPushSubscriptionInvalid
+	}
+
+	topic, err := fromContext[*topic](r, contextTopic)
+	if err != nil {
+		return err
+	}
+
+	err = s.webPushSubscriptionStore.RemoveSubscription(topic.ID, payload.Endpoint)
+	if err != nil {
+		return err
+	}
+
+	return s.writeJSON(w, newSuccessResponse())
+}
+
 func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
 	response := &apiHealthResponse{
 		Healthy: true,
@@ -564,6 +656,11 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
 	return err
 }
 
+func (s *Server) handleWebManifest(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	w.Header().Set("Content-Type", "application/manifest+json")
+	return s.handleStatic(w, r, v)
+}
+
 // handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
 // and listen-metrics-http is not set.
 func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@@ -763,6 +860,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
 			go s.forwardPollRequest(v, m)
 		}
+		if s.config.WebPushEnabled {
+			go s.publishToWebPushEndpoints(v, m)
+		}
 	} else {
 		logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
 	}
@@ -877,6 +977,95 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
 	}
 }
 
+func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
+	subscriptions, err := s.webPushSubscriptionStore.GetSubscriptionsForTopic(m.Topic)
+
+	if err != nil {
+		logvm(v, m).Err(err).Warn("Unable to publish web push messages")
+		return
+	}
+
+	failedCount := 0
+	totalCount := len(subscriptions)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(totalCount)
+
+	ctx := log.Context{"topic": m.Topic, "message_id": m.ID, "total_count": totalCount}
+
+	// Importing the emojis in the service worker would add unnecessary complexity,
+	// simply do it here for web push notifications instead
+	var titleWithDefault string
+	var formattedTitle string
+
+	emojis, _, err := toEmojis(m.Tags)
+	if err != nil {
+		logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
+		return
+	}
+
+	if m.Title == "" {
+		titleWithDefault = m.Topic
+	} else {
+		titleWithDefault = m.Title
+	}
+
+	if len(emojis) > 0 {
+		formattedTitle = fmt.Sprintf("%s %s", strings.Join(emojis[:], " "), titleWithDefault)
+	} else {
+		formattedTitle = titleWithDefault
+	}
+
+	for i, xi := range subscriptions {
+		go func(i int, sub webPushSubscription) {
+			defer wg.Done()
+			ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint, "username": sub.Username, "topic": m.Topic, "message_id": m.ID}
+
+			payload := &webPushPayload{
+				SubscriptionID: fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic),
+				Message:        *m,
+				FormattedTitle: formattedTitle,
+			}
+			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{
+				Subscriber:      s.config.WebPushEmailAddress,
+				VAPIDPublicKey:  s.config.WebPushPublicKey,
+				VAPIDPrivateKey: s.config.WebPushPrivateKey,
+				// deliverability on iOS isn't great with lower urgency values,
+				// and thus we can't really map lower ntfy priorities to lower urgency values
+				Urgency: webpush.UrgencyHigh,
+			})
+
+			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")
+				}
+			}
+		}(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) {
 	cache = readBoolParam(r, true, "x-cache", "cache")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
@@ -1692,6 +1881,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
 	if s.config.UpstreamBaseURL != "" {
 		go s.forwardPollRequest(v, m)
 	}
+	if s.config.WebPushEnabled {
+		go s.publishToWebPushEndpoints(v, m)
+	}
 	if err := s.messageCache.MarkPublished(m); err != nil {
 		return err
 	}

+ 10 - 0
server/server.yml

@@ -38,6 +38,16 @@
 #
 # firebase-key-file: <filename>
 
+# Enable web push
+#
+# Run ntfy web-push-keys to generate the keys
+#
+# web-push-enabled: true
+# web-push-public-key: ""
+# web-push-private-key: ""
+# web-push-subscriptions-file: ""
+# web-push-email-address: ""
+
 # If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
 # This allows for service restarts without losing messages in support of the since= parameter.
 #

+ 7 - 0
server/server_account.go

@@ -170,6 +170,13 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
 	if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
 		return errHTTPBadRequestIncorrectPasswordConfirmation
 	}
+	if s.webPushSubscriptionStore != nil {
+		err := s.webPushSubscriptionStore.ExpireWebPushForUser(u.Name)
+
+		if err != nil {
+			logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
+		}
+	}
 	if u.Billing.StripeSubscriptionID != "" {
 		logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
 		if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {

+ 9 - 0
server/server_middleware.go

@@ -58,6 +58,15 @@ func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
 	}
 }
 
+func (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc {
+	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
+		if !s.config.WebPushEnabled {
+			return errHTTPNotFound
+		}
+		return next(w, r, v)
+	}
+}
+
 func (s *Server) ensureUserManager(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
 		if s.userManager == nil {

+ 13 - 0
server/server_test.go

@@ -238,6 +238,12 @@ func TestServer_WebEnabled(t *testing.T) {
 	rr = request(t, s, "GET", "/config.js", "", nil)
 	require.Equal(t, 404, rr.Code)
 
+	rr = request(t, s, "GET", "/manifest.webmanifest", "", nil)
+	require.Equal(t, 404, rr.Code)
+
+	rr = request(t, s, "GET", "/sw.js", "", nil)
+	require.Equal(t, 404, rr.Code)
+
 	rr = request(t, s, "GET", "/static/css/home.css", "", nil)
 	require.Equal(t, 404, rr.Code)
 
@@ -250,6 +256,13 @@ func TestServer_WebEnabled(t *testing.T) {
 
 	rr = request(t, s2, "GET", "/config.js", "", nil)
 	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
+	require.Equal(t, 200, rr.Code)
+	require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
+
+	rr = request(t, s2, "GET", "/sw.js", "", nil)
+	require.Equal(t, 200, rr.Code)
 }
 
 func TestServer_PublishLargeMessage(t *testing.T) {

+ 0 - 24
server/smtp_sender.go

@@ -1,8 +1,6 @@
 package server
 
 import (
-	_ "embed" // required by go:embed
-	"encoding/json"
 	"fmt"
 	"mime"
 	"net"
@@ -130,25 +128,3 @@ This message was sent by {ip} at {time} via {topicURL}`
 	body = strings.ReplaceAll(body, "{ip}", senderIP)
 	return body, nil
 }
-
-var (
-	//go:embed "mailer_emoji_map.json"
-	emojisJSON string
-)
-
-func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
-	var emojiMap map[string]string
-	if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
-		return nil, nil, err
-	}
-	tagsOut = make([]string, 0)
-	emojisOut = make([]string, 0)
-	for _, t := range tags {
-		if emoji, ok := emojiMap[t]; ok {
-			emojisOut = append(emojisOut, emoji)
-		} else {
-			tagsOut = append(tagsOut, t)
-		}
-	}
-	return
-}

+ 24 - 0
server/types.go

@@ -7,6 +7,7 @@ import (
 	"net/netip"
 	"time"
 
+	"github.com/SherClockHolmes/webpush-go"
 	"heckel.io/ntfy/util"
 )
 
@@ -401,6 +402,10 @@ type apiConfigResponse struct {
 	DisallowedTopics   []string `json:"disallowed_topics"`
 }
 
+type apiWebPushConfigResponse struct {
+	PublicKey string `json:"public_key"`
+}
+
 type apiAccountBillingPrices struct {
 	Month int64 `json:"month"`
 	Year  int64 `json:"year"`
@@ -462,3 +467,22 @@ type apiStripeSubscriptionDeletedEvent struct {
 	ID       string `json:"id"`
 	Customer string `json:"customer"`
 }
+
+type webPushPayload struct {
+	SubscriptionID string  `json:"subscription_id"`
+	Message        message `json:"message"`
+	FormattedTitle string  `json:"formatted_title"`
+}
+
+type webPushSubscription struct {
+	BrowserSubscription webpush.Subscription
+	Username            string
+}
+
+type webPushSubscribePayload struct {
+	BrowserSubscription webpush.Subscription `json:"browser_subscription"`
+}
+
+type webPushUnsubscribePayload struct {
+	Endpoint string `json:"endpoint"`
+}

+ 24 - 0
server/util.go

@@ -2,6 +2,8 @@ package server
 
 import (
 	"context"
+	_ "embed" // required by go:embed
+	"encoding/json"
 	"fmt"
 	"heckel.io/ntfy/util"
 	"io"
@@ -133,3 +135,25 @@ func maybeDecodeHeader(header string) string {
 	}
 	return decoded
 }
+
+var (
+	//go:embed "mailer_emoji_map.json"
+	emojisJSON string
+)
+
+func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
+	var emojiMap map[string]string
+	if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
+		return nil, nil, err
+	}
+	tagsOut = make([]string, 0)
+	emojisOut = make([]string, 0)
+	for _, t := range tags {
+		if emoji, ok := emojiMap[t]; ok {
+			emojisOut = append(emojisOut, emoji)
+		} else {
+			tagsOut = append(tagsOut, t)
+		}
+	}
+	return
+}

+ 132 - 0
server/web_push.go

@@ -0,0 +1,132 @@
+package server
+
+import (
+	"database/sql"
+
+	_ "github.com/mattn/go-sqlite3" // SQLite driver
+)
+
+// Messages cache
+const (
+	createWebPushSubscriptionsTableQuery = `
+		BEGIN;
+		CREATE TABLE IF NOT EXISTS web_push_subscriptions (
+			id INTEGER PRIMARY KEY AUTOINCREMENT,
+			topic TEXT NOT NULL,
+			username TEXT,
+			endpoint TEXT NOT NULL,
+			key_auth TEXT NOT NULL,
+			key_p256dh TEXT NOT NULL,
+			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+		);
+		CREATE INDEX IF NOT EXISTS idx_topic ON web_push_subscriptions (topic);
+		CREATE INDEX IF NOT EXISTS idx_endpoint ON web_push_subscriptions (endpoint);
+		CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON web_push_subscriptions (topic, endpoint);
+		COMMIT;
+	`
+	insertWebPushSubscriptionQuery = `
+		INSERT OR REPLACE INTO web_push_subscriptions (topic, username, endpoint, key_auth, key_p256dh)
+		VALUES (?, ?, ?, ?, ?);
+	`
+	deleteWebPushSubscriptionByEndpointQuery         = `DELETE FROM web_push_subscriptions WHERE endpoint = ?`
+	deleteWebPushSubscriptionByUsernameQuery         = `DELETE FROM web_push_subscriptions WHERE username = ?`
+	deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM web_push_subscriptions WHERE topic = ? AND endpoint = ?`
+
+	selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, username FROM web_push_subscriptions WHERE topic = ?`
+
+	selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM web_push_subscriptions`
+)
+
+type webPushSubscriptionStore struct {
+	db *sql.DB
+}
+
+func newWebPushSubscriptionStore(filename string) (*webPushSubscriptionStore, error) {
+	db, err := sql.Open("sqlite3", filename)
+	if err != nil {
+		return nil, err
+	}
+	if err := setupSubscriptionDb(db); err != nil {
+		return nil, err
+	}
+	webPushSubscriptionStore := &webPushSubscriptionStore{
+		db: db,
+	}
+	return webPushSubscriptionStore, nil
+}
+
+func setupSubscriptionDb(db *sql.DB) error {
+	// If 'messages' table does not exist, this must be a new database
+	rowsMC, err := db.Query(selectWebPushSubscriptionsCountQuery)
+	if err != nil {
+		return setupNewSubscriptionDb(db)
+	}
+	rowsMC.Close()
+	return nil
+}
+
+func setupNewSubscriptionDb(db *sql.DB) error {
+	if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (c *webPushSubscriptionStore) AddSubscription(topic string, username string, subscription webPushSubscribePayload) error {
+	_, err := c.db.Exec(
+		insertWebPushSubscriptionQuery,
+		topic,
+		username,
+		subscription.BrowserSubscription.Endpoint,
+		subscription.BrowserSubscription.Keys.Auth,
+		subscription.BrowserSubscription.Keys.P256dh,
+	)
+	return err
+}
+
+func (c *webPushSubscriptionStore) RemoveSubscription(topic string, endpoint string) error {
+	_, err := c.db.Exec(
+		deleteWebPushSubscriptionByTopicAndEndpointQuery,
+		topic,
+		endpoint,
+	)
+	return err
+}
+
+func (c *webPushSubscriptionStore) GetSubscriptionsForTopic(topic string) (subscriptions []webPushSubscription, err error) {
+	rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	data := []webPushSubscription{}
+	for rows.Next() {
+		i := webPushSubscription{}
+		err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh, &i.Username)
+		if err != nil {
+			return nil, err
+		}
+		data = append(data, i)
+	}
+	return data, nil
+}
+
+func (c *webPushSubscriptionStore) ExpireWebPushEndpoint(endpoint string) error {
+	_, err := c.db.Exec(
+		deleteWebPushSubscriptionByEndpointQuery,
+		endpoint,
+	)
+	return err
+}
+
+func (c *webPushSubscriptionStore) ExpireWebPushForUser(username string) error {
+	_, err := c.db.Exec(
+		deleteWebPushSubscriptionByUsernameQuery,
+		username,
+	)
+	return err
+}
+func (c *webPushSubscriptionStore) Close() error {
+	return c.db.Close()
+}

+ 2 - 1
web/.eslintrc

@@ -33,5 +33,6 @@
         "unnamedComponents": "arrow-function"
       }
     ]
-  }
+  },
+  "overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
 }

+ 7 - 0
web/index.html

@@ -13,11 +13,18 @@
     <meta name="theme-color" content="#317f6f" />
     <meta name="msapplication-navbutton-color" content="#317f6f" />
     <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
+    <link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
+    <link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
 
     <!-- Favicon, see favicon.io -->
     <link rel="icon" type="image/png" href="/static/images/favicon.ico" />
 
     <!-- Previews in Google, Slack, WhatsApp, etc. -->
+
+    <meta
+      name="description"
+      content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
+    />
     <meta property="og:type" content="website" />
     <meta property="og:locale" content="en_US" />
     <meta property="og:site_name" content="ntfy web" />

File diff ditekan karena terlalu besar
+ 1328 - 88
web/package-lock.json


+ 2 - 1
web/package.json

@@ -40,7 +40,8 @@
     "eslint-plugin-react": "^7.32.2",
     "eslint-plugin-react-hooks": "^4.6.0",
     "prettier": "^2.8.8",
-    "vite": "^4.3.9"
+    "vite": "^4.3.9",
+    "vite-plugin-pwa": "^0.15.0"
   },
   "browserslist": {
     "production": [

+ 1 - 1
web/public/config.js

@@ -7,7 +7,7 @@
 
 var config = {
   base_url: window.location.origin, // Change to test against a different server
-  app_root: "/app",
+  app_root: "/",
   enable_login: true,
   enable_signup: true,
   enable_payments: false,

TEMPAT SAMPAH
web/public/static/images/apple-touch-icon.png


+ 20 - 0
web/public/static/images/mask-icon.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
+ preserveAspectRatio="xMidYMid meet">
+<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M1546 6263 c-1 -1 -132 -3 -292 -4 -301 -1 -353 -7 -484 -50 -265
+-88 -483 -296 -578 -550 -52 -140 -54 -172 -53 -784 2 -2183 1 -3783 -3 -3802
+-2 -12 -7 -49 -11 -82 -3 -33 -7 -68 -9 -78 -2 -10 -7 -45 -12 -78 -4 -33 -8
+-62 -9 -65 0 -3 -5 -36 -10 -75 -5 -38 -9 -72 -10 -75 -1 -3 -5 -34 -10 -70
+-12 -98 -12 -96 -30 -225 -9 -66 -19 -123 -21 -127 -15 -24 16 -17 686 162
+107 29 200 53 205 54 6 2 30 8 55 15 25 7 140 37 255 68 116 30 282 75 370 98
+l160 43 2175 0 c1196 0 2201 3 2234 7 210 21 414 120 572 279 118 119 188 237
+236 403 l23 78 2 2025 2 2025 -25 99 c-23 94 -87 247 -116 277 -7 8 -26 33
+-41 56 -97 142 -326 296 -512 342 -27 7 -59 15 -70 18 -11 3 -94 7 -185 10
+-165 4 -4490 10 -4494 6z"/>
+</g>
+</svg>

TEMPAT SAMPAH
web/public/static/images/pwa-192x192.png


TEMPAT SAMPAH
web/public/static/images/pwa-512x512.png


+ 15 - 3
web/public/static/langs/en.json

@@ -52,9 +52,10 @@
   "nav_button_connecting": "connecting",
   "nav_upgrade_banner_label": "Upgrade to ntfy Pro",
   "nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
-  "alert_grant_title": "Notifications are disabled",
-  "alert_grant_description": "Grant your browser permission to display desktop notifications.",
-  "alert_grant_button": "Grant now",
+  "alert_notification_permission_denied_title": "Notifications are blocked",
+  "alert_notification_permission_denied_description": "Please re-enable them in your browser and refresh the page to receive notifications",
+  "alert_notification_ios_install_required_title": "iOS Install Required",
+  "alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS",
   "alert_not_supported_title": "Notifications not supported",
   "alert_not_supported_description": "Notifications are not supported in your browser.",
   "alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
@@ -92,6 +93,10 @@
   "notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
   "notifications_example": "Example",
   "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
+  "notification_toggle_unmute": "Unmute",
+  "notification_toggle_sound": "Sound only",
+  "notification_toggle_browser": "Browser notifications",
+  "notification_toggle_background": "Browser and background notifications",
   "display_name_dialog_title": "Change display name",
   "display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
   "display_name_dialog_placeholder": "Display name",
@@ -164,6 +169,8 @@
   "subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
   "subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
   "subscribe_dialog_subscribe_use_another_label": "Use another server",
+  "subscribe_dialog_subscribe_enable_browser_notifications_label": "Notify me via browser notifications",
+  "subscribe_dialog_subscribe_enable_background_notifications_label": "Also notify me when ntfy is not open (web push)",
   "subscribe_dialog_subscribe_base_url_label": "Service URL",
   "subscribe_dialog_subscribe_button_generate_topic_name": "Generate name",
   "subscribe_dialog_subscribe_button_cancel": "Cancel",
@@ -363,6 +370,11 @@
   "prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
   "prefs_reservations_dialog_topic_label": "Topic",
   "prefs_reservations_dialog_access_label": "Access",
+  "prefs_notifications_web_push_default_title": "Enable web push notifications by default",
+  "prefs_notifications_web_push_default_description": "This affects the initial state in the subscribe dialog, as well as the default state for synced topics",
+  "prefs_notifications_web_push_default_initial": "Unset",
+  "prefs_notifications_web_push_default_enabled": "Enabled",
+  "prefs_notifications_web_push_default_disabled": "Disabled",
   "reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
   "reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
   "reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",

+ 111 - 0
web/public/sw.js

@@ -0,0 +1,111 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
+import { NavigationRoute, registerRoute } from "workbox-routing";
+import { NetworkFirst } from "workbox-strategies";
+
+import { getDbAsync } from "../src/app/getDb";
+
+// See WebPushWorker, this is to play a sound on supported browsers,
+// if the app is in the foreground
+const broadcastChannel = new BroadcastChannel("web-push-broadcast");
+
+self.addEventListener("install", () => {
+  console.log("[ServiceWorker] Installed");
+  self.skipWaiting();
+});
+
+self.addEventListener("activate", () => {
+  console.log("[ServiceWorker] Activated");
+  self.skipWaiting();
+});
+
+// There's no good way to test this, and Chrome doesn't seem to implement this,
+// so leaving it for now
+self.addEventListener("pushsubscriptionchange", (event) => {
+  console.log("[ServiceWorker] PushSubscriptionChange");
+  console.log(event);
+});
+
+self.addEventListener("push", (event) => {
+  console.log("[ServiceWorker] Received Web Push Event", { event });
+  // server/types.go webPushPayload
+  const data = event.data.json();
+
+  const { formatted_title: formattedTitle, subscription_id: subscriptionId, message } = data;
+  broadcastChannel.postMessage(message);
+
+  event.waitUntil(
+    (async () => {
+      const db = await getDbAsync();
+
+      await Promise.all([
+        (async () => {
+          await db.notifications.add({
+            ...message,
+            subscriptionId,
+            // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
+            new: 1,
+          });
+          const badgeCount = await db.notifications.where({ new: 1 }).count();
+          console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
+          self.navigator.setAppBadge?.(badgeCount);
+        })(),
+        db.subscriptions.update(subscriptionId, {
+          last: message.id,
+        }),
+        self.registration.showNotification(formattedTitle, {
+          tag: subscriptionId,
+          body: message.message,
+          icon: "/static/images/ntfy.png",
+          data,
+        }),
+      ]);
+    })()
+  );
+});
+
+self.addEventListener("notificationclick", (event) => {
+  event.notification.close();
+
+  const { message } = event.notification.data;
+
+  if (message.click) {
+    self.clients.openWindow(message.click);
+    return;
+  }
+
+  const rootUrl = new URL(self.location.origin);
+  const topicUrl = new URL(message.topic, self.location.origin);
+
+  event.waitUntil(
+    (async () => {
+      const clients = await self.clients.matchAll({ type: "window" });
+
+      const topicClient = clients.find((client) => client.url === topicUrl.toString());
+      if (topicClient) {
+        topicClient.focus();
+        return;
+      }
+
+      const rootClient = clients.find((client) => client.url === rootUrl.toString());
+      if (rootClient) {
+        rootClient.focus();
+        return;
+      }
+
+      self.clients.openWindow(topicUrl);
+    })()
+  );
+});
+
+// self.__WB_MANIFEST is default injection point
+// eslint-disable-next-line no-underscore-dangle
+precacheAndRoute(self.__WB_MANIFEST);
+
+// clean old assets
+cleanupOutdatedCaches();
+
+// to allow work offline
+registerRoute(new NavigationRoute(createHandlerBoundToURL("/")));
+
+registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst());

+ 4 - 0
web/src/app/AccountApi.js

@@ -382,6 +382,10 @@ class AccountApi {
     setTimeout(() => this.runWorker(), delayMillis);
   }
 
+  stopWorker() {
+    clearTimeout(this.timer);
+  }
+
   async runWorker() {
     if (!session.token()) {
       return;

+ 59 - 0
web/src/app/Api.js

@@ -6,6 +6,9 @@ import {
   topicUrlAuth,
   topicUrlJsonPoll,
   topicUrlJsonPollWithSince,
+  topicUrlWebPushSubscribe,
+  topicUrlWebPushUnsubscribe,
+  webPushConfigUrl,
 } from "./utils";
 import userManager from "./UserManager";
 import { fetchOrThrow } from "./errors";
@@ -113,6 +116,62 @@ class Api {
     }
     throw new Error(`Unexpected server response ${response.status}`);
   }
+
+  /**
+   * @returns {Promise<{ public_key: string } | undefined>}
+   */
+  async getWebPushConfig(baseUrl) {
+    const response = await fetch(webPushConfigUrl(baseUrl));
+
+    if (response.ok) {
+      return response.json();
+    }
+
+    if (response.status === 404) {
+      // web push is not enabled
+      return undefined;
+    }
+
+    throw new Error(`Unexpected server response ${response.status}`);
+  }
+
+  async subscribeWebPush(baseUrl, topic, browserSubscription) {
+    const user = await userManager.get(baseUrl);
+
+    const url = topicUrlWebPushSubscribe(baseUrl, topic);
+    console.log(`[Api] Sending Web Push Subscription ${url}`);
+
+    const response = await fetch(url, {
+      method: "POST",
+      headers: maybeWithAuth({}, user),
+      body: JSON.stringify({ browser_subscription: browserSubscription }),
+    });
+
+    if (response.ok) {
+      return true;
+    }
+
+    throw new Error(`Unexpected server response ${response.status}`);
+  }
+
+  async unsubscribeWebPush(subscription) {
+    const user = await userManager.get(subscription.baseUrl);
+
+    const url = topicUrlWebPushUnsubscribe(subscription.baseUrl, subscription.topic);
+    console.log(`[Api] Unsubscribing Web Push Subscription ${url}`);
+
+    const response = await fetch(url, {
+      method: "POST",
+      headers: maybeWithAuth({}, user),
+      body: JSON.stringify({ endpoint: subscription.webPushEndpoint }),
+    });
+
+    if (response.ok) {
+      return true;
+    }
+
+    throw new Error(`Unexpected server response ${response.status}`);
+  }
 }
 
 const api = new Api();

+ 12 - 5
web/src/app/ConnectionManager.js

@@ -1,7 +1,8 @@
 import Connection from "./Connection";
+import { NotificationType } from "./SubscriptionManager";
 import { hashCode } from "./utils";
 
-const makeConnectionId = async (subscription, user) =>
+const makeConnectionId = (subscription, user) =>
   user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
 
 /**
@@ -45,13 +46,19 @@ class ConnectionManager {
       return;
     }
     console.log(`[ConnectionManager] Refreshing connections`);
-    const subscriptionsWithUsersAndConnectionId = await Promise.all(
-      subscriptions.map(async (s) => {
+    const subscriptionsWithUsersAndConnectionId = subscriptions
+      .map((s) => {
         const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
-        const connectionId = await makeConnectionId(s, user);
+        const connectionId = makeConnectionId(s, user);
         return { ...s, user, connectionId };
       })
-    );
+      // we want to create a ws for both sound-only and active browser notifications,
+      // only background notifications don't need this as they come over web push.
+      // however, if background notifications are muted, we again need the ws while
+      // the page is active
+      .filter((s) => s.notificationType !== NotificationType.BACKGROUND && s.mutedUntil !== 1);
+
+    console.log();
     const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
     const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
 

+ 75 - 27
web/src/app/Notifier.js

@@ -1,22 +1,18 @@
-import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils";
+import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
 import prefs from "./Prefs";
-import subscriptionManager from "./SubscriptionManager";
 import logo from "../img/ntfy.png";
+import api from "./Api";
 
 /**
  * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers
  * support this; most importantly, all iOS browsers do not support window.Notification.
  */
 class Notifier {
-  async notify(subscriptionId, notification, onClickFallback) {
+  async notify(subscription, notification, onClickFallback) {
     if (!this.supported()) {
       return;
     }
-    const subscription = await subscriptionManager.get(subscriptionId);
-    const shouldNotify = await this.shouldNotify(subscription, notification);
-    if (!shouldNotify) {
-      return;
-    }
+
     const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
     const displayName = topicDisplayName(subscription);
     const message = formatMessage(notification);
@@ -26,6 +22,7 @@ class Notifier {
     console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
     const n = new Notification(title, {
       body: message,
+      tag: subscription.id,
       icon: logo,
     });
     if (notification.click) {
@@ -33,45 +30,88 @@ class Notifier {
     } else {
       n.onclick = () => onClickFallback(subscription);
     }
+  }
 
+  async playSound() {
     // Play sound
     const sound = await prefs.sound();
     if (sound && sound !== "none") {
       try {
         await playSound(sound);
       } catch (e) {
-        console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
+        console.log(`[Notifier] Error playing audio`, e);
       }
     }
   }
 
-  granted() {
-    return this.supported() && Notification.permission === "granted";
+  async unsubscribeWebPush(subscription) {
+    try {
+      await api.unsubscribeWebPush(subscription);
+    } catch (e) {
+      console.error("[Notifier.subscribeWebPush] Error subscribing to web push", e);
+    }
   }
 
-  maybeRequestPermission(cb) {
-    if (!this.supported()) {
-      cb(false);
-      return;
+  async subscribeWebPush(baseUrl, topic) {
+    if (!this.supported() || !this.pushSupported()) {
+      return {};
     }
-    if (!this.granted()) {
-      Notification.requestPermission().then((permission) => {
-        const granted = permission === "granted";
-        cb(granted);
+
+    // only subscribe to web push for the current server. this is a limitation of the web push API,
+    // which only allows a single server per service worker origin.
+    if (baseUrl !== config.base_url) {
+      return {};
+    }
+
+    const registration = await navigator.serviceWorker.getRegistration();
+
+    if (!registration) {
+      console.log("[Notifier.subscribeWebPush] Web push supported but no service worker registration found, skipping");
+      return {};
+    }
+
+    try {
+      const webPushConfig = await api.getWebPushConfig(baseUrl);
+
+      if (!webPushConfig) {
+        console.log("[Notifier.subscribeWebPush] Web push not configured on server");
+      }
+
+      const browserSubscription = await registration.pushManager.subscribe({
+        userVisibleOnly: true,
+        applicationServerKey: urlB64ToUint8Array(webPushConfig.public_key),
       });
+
+      await api.subscribeWebPush(baseUrl, topic, browserSubscription);
+
+      console.log("[Notifier.subscribeWebPush] Successfully subscribed to web push");
+
+      return browserSubscription;
+    } catch (e) {
+      console.error("[Notifier.subscribeWebPush] Error subscribing to web push", e);
     }
+
+    return {};
   }
 
-  async shouldNotify(subscription, notification) {
-    if (subscription.mutedUntil === 1) {
-      return false;
-    }
-    const priority = notification.priority ? notification.priority : 3;
-    const minPriority = await prefs.minPriority();
-    if (priority < minPriority) {
+  granted() {
+    return this.supported() && Notification.permission === "granted";
+  }
+
+  denied() {
+    return this.supported() && Notification.permission === "denied";
+  }
+
+  async maybeRequestPermission() {
+    if (!this.supported()) {
       return false;
     }
-    return true;
+
+    return new Promise((resolve) => {
+      Notification.requestPermission((permission) => {
+        resolve(permission === "granted");
+      });
+    });
   }
 
   supported() {
@@ -82,6 +122,10 @@ class Notifier {
     return "Notification" in window;
   }
 
+  pushSupported() {
+    return "serviceWorker" in navigator && "PushManager" in window;
+  }
+
   /**
    * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
    * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
@@ -89,6 +133,10 @@ class Notifier {
   contextSupported() {
     return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
   }
+
+  iosSupportedButInstallRequired() {
+    return "standalone" in window.navigator && window.navigator.standalone === false;
+  }
 }
 
 const notifier = new Notifier();

+ 6 - 3
web/src/app/Poller.js

@@ -18,6 +18,10 @@ class Poller {
     setTimeout(() => this.pollAll(), delayMillis);
   }
 
+  stopWorker() {
+    clearTimeout(this.timer);
+  }
+
   async pollAll() {
     console.log(`[Poller] Polling all subscriptions`);
     const subscriptions = await subscriptionManager.all();
@@ -47,14 +51,13 @@ class Poller {
   }
 
   pollInBackground(subscription) {
-    const fn = async () => {
+    (async () => {
       try {
         await this.poll(subscription);
       } catch (e) {
         console.error(`[App] Error polling subscription ${subscription.id}`, e);
       }
-    };
-    setTimeout(() => fn(), 0);
+    })();
   }
 }
 

+ 21 - 9
web/src/app/Prefs.js

@@ -1,33 +1,45 @@
-import db from "./db";
+import getDb from "./getDb";
 
 class Prefs {
+  constructor(db) {
+    this.db = db;
+  }
+
   async setSound(sound) {
-    db.prefs.put({ key: "sound", value: sound.toString() });
+    this.db.prefs.put({ key: "sound", value: sound.toString() });
   }
 
   async sound() {
-    const sound = await db.prefs.get("sound");
+    const sound = await this.db.prefs.get("sound");
     return sound ? sound.value : "ding";
   }
 
   async setMinPriority(minPriority) {
-    db.prefs.put({ key: "minPriority", value: minPriority.toString() });
+    this.db.prefs.put({ key: "minPriority", value: minPriority.toString() });
   }
 
   async minPriority() {
-    const minPriority = await db.prefs.get("minPriority");
+    const minPriority = await this.db.prefs.get("minPriority");
     return minPriority ? Number(minPriority.value) : 1;
   }
 
   async setDeleteAfter(deleteAfter) {
-    db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
+    this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
   }
 
   async deleteAfter() {
-    const deleteAfter = await db.prefs.get("deleteAfter");
+    const deleteAfter = await this.db.prefs.get("deleteAfter");
     return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
   }
+
+  async webPushDefaultEnabled() {
+    const obj = await this.db.prefs.get("webPushDefaultEnabled");
+    return obj?.value ?? "initial";
+  }
+
+  async setWebPushDefaultEnabled(enabled) {
+    await this.db.prefs.put({ key: "webPushDefaultEnabled", value: enabled ? "enabled" : "disabled" });
+  }
 }
 
-const prefs = new Prefs();
-export default prefs;
+export default new Prefs(getDb());

+ 4 - 0
web/src/app/Pruner.js

@@ -18,6 +18,10 @@ class Pruner {
     setTimeout(() => this.prune(), delayMillis);
   }
 
+  stopWorker() {
+    clearTimeout(this.timer);
+  }
+
   async prune() {
     const deleteAfterSeconds = await prefs.deleteAfter();
     const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;

+ 11 - 1
web/src/app/Session.js

@@ -1,12 +1,22 @@
+import sessionReplica from "./SessionReplica";
+
 class Session {
+  constructor(replica) {
+    this.replica = replica;
+  }
+
   store(username, token) {
     localStorage.setItem("user", username);
     localStorage.setItem("token", token);
+
+    this.replica.store(username, token);
   }
 
   reset() {
     localStorage.removeItem("user");
     localStorage.removeItem("token");
+
+    this.replica.reset();
   }
 
   resetAndRedirect(url) {
@@ -27,5 +37,5 @@ class Session {
   }
 }
 
-const session = new Session();
+const session = new Session(sessionReplica);
 export default session;

+ 44 - 0
web/src/app/SessionReplica.js

@@ -0,0 +1,44 @@
+import Dexie from "dexie";
+
+// Store to IndexedDB as well so that the
+// service worker can access it
+// TODO: Probably make everything depend on this and not use localStorage,
+// but that's a larger refactoring effort for another PR
+
+class SessionReplica {
+  constructor() {
+    const db = new Dexie("session-replica");
+
+    db.version(1).stores({
+      keyValueStore: "&key",
+    });
+
+    this.db = db;
+  }
+
+  async store(username, token) {
+    try {
+      await this.db.keyValueStore.bulkPut([
+        { key: "user", value: username },
+        { key: "token", value: token },
+      ]);
+    } catch (e) {
+      console.error("[Session] Error replicating session to IndexedDB", e);
+    }
+  }
+
+  async reset() {
+    try {
+      await this.db.delete();
+    } catch (e) {
+      console.error("[Session] Error resetting session on IndexedDB", e);
+    }
+  }
+
+  async username() {
+    return (await this.db.keyValueStore.get({ key: "user" }))?.value;
+  }
+}
+
+const sessionReplica = new SessionReplica();
+export default sessionReplica;

+ 163 - 35
web/src/app/SubscriptionManager.js

@@ -1,47 +1,112 @@
-import db from "./db";
+import notifier from "./Notifier";
+import prefs from "./Prefs";
+import getDb from "./getDb";
 import { topicUrl } from "./utils";
 
+/** @typedef {string} NotificationTypeEnum */
+
+/** @enum {NotificationTypeEnum} */
+export const NotificationType = {
+  /** sound-only */
+  SOUND: "sound",
+  /** browser notifications when there is an active tab, via websockets */
+  BROWSER: "browser",
+  /** web push notifications, regardless of whether the window is open */
+  BACKGROUND: "background",
+};
+
 class SubscriptionManager {
+  constructor(db) {
+    this.db = db;
+  }
+
   /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
   async all() {
-    const subscriptions = await db.subscriptions.toArray();
+    const subscriptions = await this.db.subscriptions.toArray();
     return Promise.all(
       subscriptions.map(async (s) => ({
         ...s,
-        new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
+        new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
       }))
     );
   }
 
   async get(subscriptionId) {
-    return db.subscriptions.get(subscriptionId);
+    return this.db.subscriptions.get(subscriptionId);
+  }
+
+  async notify(subscriptionId, notification, defaultClickAction) {
+    const subscription = await this.get(subscriptionId);
+
+    if (subscription.mutedUntil === 1) {
+      return;
+    }
+
+    const priority = notification.priority ?? 3;
+    if (priority < (await prefs.minPriority())) {
+      return;
+    }
+
+    await notifier.playSound();
+
+    // sound only
+    if (subscription.notificationType === "sound") {
+      return;
+    }
+
+    await notifier.notify(subscription, notification, defaultClickAction);
   }
 
-  async add(baseUrl, topic, internal) {
+  /**
+   * @param {string} baseUrl
+   * @param {string} topic
+   * @param {object} opts
+   * @param {boolean} opts.internal
+   * @param {NotificationTypeEnum} opts.notificationType
+   * @returns
+   */
+  async add(baseUrl, topic, opts = {}) {
     const id = topicUrl(baseUrl, topic);
+
+    const webPushFields = opts.notificationType === "background" ? await notifier.subscribeWebPush(baseUrl, topic) : {};
+
     const existingSubscription = await this.get(id);
     if (existingSubscription) {
+      if (webPushFields.endpoint) {
+        await this.db.subscriptions.update(existingSubscription.id, {
+          webPushEndpoint: webPushFields.endpoint,
+        });
+      }
+
       return existingSubscription;
     }
+
     const subscription = {
       id: topicUrl(baseUrl, topic),
       baseUrl,
       topic,
       mutedUntil: 0,
       last: null,
-      internal: internal || false,
+      ...opts,
+      webPushEndpoint: webPushFields.endpoint,
     };
-    await db.subscriptions.put(subscription);
+
+    await this.db.subscriptions.put(subscription);
+
     return subscription;
   }
 
   async syncFromRemote(remoteSubscriptions, remoteReservations) {
     console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
 
+    const notificationType = (await prefs.webPushDefaultEnabled()) === "enabled" ? "background" : "browser";
+
     // Add remote subscriptions
     const remoteIds = await Promise.all(
       remoteSubscriptions.map(async (remote) => {
-        const local = await this.add(remote.base_url, remote.topic, false);
+        const local = await this.add(remote.base_url, remote.topic, {
+          notificationType,
+        });
         const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
 
         await this.update(local.id, {
@@ -54,29 +119,33 @@ class SubscriptionManager {
     );
 
     // Remove local subscriptions that do not exist remotely
-    const localSubscriptions = await db.subscriptions.toArray();
+    const localSubscriptions = await this.db.subscriptions.toArray();
 
     await Promise.all(
       localSubscriptions.map(async (local) => {
         const remoteExists = remoteIds.includes(local.id);
         if (!local.internal && !remoteExists) {
-          await this.remove(local.id);
+          await this.remove(local);
         }
       })
     );
   }
 
   async updateState(subscriptionId, state) {
-    db.subscriptions.update(subscriptionId, { state });
+    this.db.subscriptions.update(subscriptionId, { state });
   }
 
-  async remove(subscriptionId) {
-    await db.subscriptions.delete(subscriptionId);
-    await db.notifications.where({ subscriptionId }).delete();
+  async remove(subscription) {
+    await this.db.subscriptions.delete(subscription.id);
+    await this.db.notifications.where({ subscriptionId: subscription.id }).delete();
+
+    if (subscription.webPushEndpoint) {
+      await notifier.unsubscribeWebPush(subscription);
+    }
   }
 
   async first() {
-    return db.subscriptions.toCollection().first(); // May be undefined
+    return this.db.subscriptions.toCollection().first(); // May be undefined
   }
 
   async getNotifications(subscriptionId) {
@@ -84,7 +153,7 @@ class SubscriptionManager {
     // It's actually fine, because the reading and filtering is quite fast. The rendering is what's
     // killing performance. See  https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
 
-    return db.notifications
+    return this.db.notifications
       .orderBy("time") // Sort by time first
       .filter((n) => n.subscriptionId === subscriptionId)
       .reverse()
@@ -92,7 +161,7 @@ class SubscriptionManager {
   }
 
   async getAllNotifications() {
-    return db.notifications
+    return this.db.notifications
       .orderBy("time") // Efficient, see docs
       .reverse()
       .toArray();
@@ -100,18 +169,19 @@ class SubscriptionManager {
 
   /** Adds notification, or returns false if it already exists */
   async addNotification(subscriptionId, notification) {
-    const exists = await db.notifications.get(notification.id);
+    const exists = await this.db.notifications.get(notification.id);
     if (exists) {
       return false;
     }
     try {
-      await db.notifications.add({
+      // sw.js duplicates this logic, so if you change it here, change it there too
+      await this.db.notifications.add({
         ...notification,
         subscriptionId,
         // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
         new: 1,
       }); // FIXME consider put() for double tab
-      await db.subscriptions.update(subscriptionId, {
+      await this.db.subscriptions.update(subscriptionId, {
         last: notification.id,
       });
     } catch (e) {
@@ -124,19 +194,19 @@ class SubscriptionManager {
   async addNotifications(subscriptionId, notifications) {
     const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
     const lastNotificationId = notifications.at(-1).id;
-    await db.notifications.bulkPut(notificationsWithSubscriptionId);
-    await db.subscriptions.update(subscriptionId, {
+    await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
+    await this.db.subscriptions.update(subscriptionId, {
       last: lastNotificationId,
     });
   }
 
   async updateNotification(notification) {
-    const exists = await db.notifications.get(notification.id);
+    const exists = await this.db.notifications.get(notification.id);
     if (!exists) {
       return false;
     }
     try {
-      await db.notifications.put({ ...notification });
+      await this.db.notifications.put({ ...notification });
     } catch (e) {
       console.error(`[SubscriptionManager] Error updating notification`, e);
     }
@@ -144,47 +214,105 @@ class SubscriptionManager {
   }
 
   async deleteNotification(notificationId) {
-    await db.notifications.delete(notificationId);
+    await this.db.notifications.delete(notificationId);
   }
 
   async deleteNotifications(subscriptionId) {
-    await db.notifications.where({ subscriptionId }).delete();
+    await this.db.notifications.where({ subscriptionId }).delete();
   }
 
   async markNotificationRead(notificationId) {
-    await db.notifications.where({ id: notificationId }).modify({ new: 0 });
+    await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
   }
 
   async markNotificationsRead(subscriptionId) {
-    await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
+    await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
   }
 
   async setMutedUntil(subscriptionId, mutedUntil) {
-    await db.subscriptions.update(subscriptionId, {
+    await this.db.subscriptions.update(subscriptionId, {
       mutedUntil,
     });
+
+    const subscription = await this.get(subscriptionId);
+
+    if (subscription.notificationType === "background") {
+      if (mutedUntil === 1) {
+        await notifier.unsubscribeWebPush(subscription);
+      } else {
+        const webPushFields = await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
+        await this.db.subscriptions.update(subscriptionId, {
+          webPushEndpoint: webPushFields.endpoint,
+        });
+      }
+    }
+  }
+
+  /**
+   *
+   * @param {object} subscription
+   * @param {NotificationTypeEnum} newNotificationType
+   * @returns
+   */
+  async setNotificationType(subscription, newNotificationType) {
+    const oldNotificationType = subscription.notificationType ?? "browser";
+
+    if (oldNotificationType === newNotificationType) {
+      return;
+    }
+
+    let { webPushEndpoint } = subscription;
+
+    if (oldNotificationType === "background") {
+      await notifier.unsubscribeWebPush(subscription);
+      webPushEndpoint = undefined;
+    } else if (newNotificationType === "background") {
+      const webPushFields = await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
+      webPushEndpoint = webPushFields.webPushEndpoint;
+    }
+
+    await this.db.subscriptions.update(subscription.id, {
+      notificationType: newNotificationType,
+      webPushEndpoint,
+    });
+  }
+
+  // for logout/delete, unsubscribe first to prevent receiving dangling notifications
+  async unsubscribeAllWebPush() {
+    const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
+    await Promise.all(subscriptions.map((subscription) => notifier.unsubscribeWebPush(subscription)));
+  }
+
+  async refreshWebPushSubscriptions() {
+    const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
+    const browserSubscription = await (await navigator.serviceWorker.getRegistration())?.pushManager?.getSubscription();
+
+    if (browserSubscription) {
+      await Promise.all(subscriptions.map((subscription) => notifier.subscribeWebPush(subscription.baseUrl, subscription.topic)));
+    } else {
+      await Promise.all(subscriptions.map((subscription) => this.setNotificationType(subscription, "sound")));
+    }
   }
 
   async setDisplayName(subscriptionId, displayName) {
-    await db.subscriptions.update(subscriptionId, {
+    await this.db.subscriptions.update(subscriptionId, {
       displayName,
     });
   }
 
   async setReservation(subscriptionId, reservation) {
-    await db.subscriptions.update(subscriptionId, {
+    await this.db.subscriptions.update(subscriptionId, {
       reservation,
     });
   }
 
   async update(subscriptionId, params) {
-    await db.subscriptions.update(subscriptionId, params);
+    await this.db.subscriptions.update(subscriptionId, params);
   }
 
   async pruneNotifications(thresholdTimestamp) {
-    await db.notifications.where("time").below(thresholdTimestamp).delete();
+    await this.db.notifications.where("time").below(thresholdTimestamp).delete();
   }
 }
 
-const subscriptionManager = new SubscriptionManager();
-export default subscriptionManager;
+export default new SubscriptionManager(getDb());

+ 10 - 7
web/src/app/UserManager.js

@@ -1,9 +1,13 @@
-import db from "./db";
+import getDb from "./getDb";
 import session from "./Session";
 
 class UserManager {
+  constructor(db) {
+    this.db = db;
+  }
+
   async all() {
-    const users = await db.users.toArray();
+    const users = await this.db.users.toArray();
     if (session.exists()) {
       users.unshift(this.localUser());
     }
@@ -14,21 +18,21 @@ class UserManager {
     if (session.exists() && baseUrl === config.base_url) {
       return this.localUser();
     }
-    return db.users.get(baseUrl);
+    return this.db.users.get(baseUrl);
   }
 
   async save(user) {
     if (session.exists() && user.baseUrl === config.base_url) {
       return;
     }
-    await db.users.put(user);
+    await this.db.users.put(user);
   }
 
   async delete(baseUrl) {
     if (session.exists() && baseUrl === config.base_url) {
       return;
     }
-    await db.users.delete(baseUrl);
+    await this.db.users.delete(baseUrl);
   }
 
   localUser() {
@@ -43,5 +47,4 @@ class UserManager {
   }
 }
 
-const userManager = new UserManager();
-export default userManager;
+export default new UserManager(getDb());

+ 46 - 0
web/src/app/WebPushWorker.js

@@ -0,0 +1,46 @@
+import notifier from "./Notifier";
+import subscriptionManager from "./SubscriptionManager";
+
+const onMessage = () => {
+  notifier.playSound();
+};
+
+const delayMillis = 2000; // 2 seconds
+const intervalMillis = 300000; // 5 minutes
+
+class WebPushWorker {
+  constructor() {
+    this.timer = null;
+  }
+
+  startWorker() {
+    if (this.timer !== null) {
+      return;
+    }
+
+    this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis);
+    setTimeout(() => this.updateSubscriptions(), delayMillis);
+
+    this.broadcastChannel = new BroadcastChannel("web-push-broadcast");
+    this.broadcastChannel.addEventListener("message", onMessage);
+  }
+
+  stopWorker() {
+    clearTimeout(this.timer);
+
+    this.broadcastChannel.removeEventListener("message", onMessage);
+    this.broadcastChannel.close();
+  }
+
+  async updateSubscriptions() {
+    try {
+      console.log("[WebPushBroadcastListener] Refreshing web push subscriptions");
+
+      await subscriptionManager.refreshWebPushSubscriptions();
+    } catch (e) {
+      console.error("[WebPushBroadcastListener] Error refreshing web push subscriptions", e);
+    }
+  }
+}
+
+export default new WebPushWorker();

+ 0 - 21
web/src/app/db.js

@@ -1,21 +0,0 @@
-import Dexie from "dexie";
-import session from "./Session";
-
-// Uses Dexie.js
-// https://dexie.org/docs/API-Reference#quick-reference
-//
-// Notes:
-// - As per docs, we only declare the indexable columns, not all columns
-
-// The IndexedDB database name is based on the logged-in user
-const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
-const db = new Dexie(dbName);
-
-db.version(1).stores({
-  subscriptions: "&id,baseUrl",
-  notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
-  users: "&baseUrl,username",
-  prefs: "&key",
-});
-
-export default db;

+ 34 - 0
web/src/app/getDb.js

@@ -0,0 +1,34 @@
+import Dexie from "dexie";
+import session from "./Session";
+import sessionReplica from "./SessionReplica";
+
+// Uses Dexie.js
+// https://dexie.org/docs/API-Reference#quick-reference
+//
+// Notes:
+// - As per docs, we only declare the indexable columns, not all columns
+
+const getDbBase = (username) => {
+  // The IndexedDB database name is based on the logged-in user
+  const dbName = username ? `ntfy-${username}` : "ntfy";
+  const db = new Dexie(dbName);
+
+  db.version(2).stores({
+    subscriptions: "&id,baseUrl,notificationType",
+    notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
+    users: "&baseUrl,username",
+    prefs: "&key",
+  });
+
+  return db;
+};
+
+export const getDbAsync = async () => {
+  const username = await sessionReplica.username();
+
+  return getDbBase(username);
+};
+
+const getDb = () => getDbBase(session.username());
+
+export default getDb;

+ 17 - 1
web/src/app/utils.js

@@ -20,7 +20,10 @@ 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 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`;
 export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
 export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
 export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
@@ -156,7 +159,7 @@ export const splitNoEmpty = (s, delimiter) =>
     .filter((x) => x !== "");
 
 /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
-export const hashCode = async (s) => {
+export const hashCode = (s) => {
   let hash = 0;
   for (let i = 0; i < s.length; i += 1) {
     const char = s.charCodeAt(i);
@@ -288,3 +291,16 @@ export const randomAlphanumericString = (len) => {
   }
   return id;
 };
+
+export const urlB64ToUint8Array = (base64String) => {
+  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; i += 1) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+};

+ 5 - 2
web/src/components/Account.jsx

@@ -48,7 +48,7 @@ import routes from "./routes";
 import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
 import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
 import { Pref, PrefGroup } from "./Pref";
-import db from "../app/db";
+import getDb from "../app/getDb";
 import UpgradeDialog from "./UpgradeDialog";
 import { AccountContext } from "./App";
 import DialogFooter from "./DialogFooter";
@@ -57,6 +57,7 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
 import { ProChip } from "./SubscriptionPopup";
 import theme from "./theme";
 import session from "../app/Session";
+import subscriptionManager from "../app/SubscriptionManager";
 
 const Account = () => {
   if (!session.exists()) {
@@ -1077,8 +1078,10 @@ const DeleteAccountDialog = (props) => {
 
   const handleSubmit = async () => {
     try {
+      await subscriptionManager.unsubscribeAllWebPush();
+
       await accountApi.delete(password);
-      await db.delete();
+      await getDb().delete();
       console.debug(`[Account] Account deleted`);
       session.resetAndRedirect(routes.app);
     } catch (e) {

+ 4 - 2
web/src/components/ActionBar.jsx

@@ -13,7 +13,7 @@ import session from "../app/Session";
 import logo from "../img/ntfy.svg";
 import subscriptionManager from "../app/SubscriptionManager";
 import routes from "./routes";
-import db from "../app/db";
+import getDb from "../app/getDb";
 import { topicDisplayName } from "../app/utils";
 import Navigation from "./Navigation";
 import accountApi from "../app/AccountApi";
@@ -120,8 +120,10 @@ const ProfileIcon = () => {
 
   const handleLogout = async () => {
     try {
+      await subscriptionManager.unsubscribeAllWebPush();
+
       await accountApi.logout();
-      await db.delete();
+      await getDb().delete();
     } finally {
       session.resetAndRedirect(routes.app);
     }

+ 4 - 0
web/src/components/App.jsx

@@ -57,6 +57,10 @@ const App = () => {
 
 const updateTitle = (newNotificationsCount) => {
   document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
+
+  if ("setAppBadge" in window.navigator) {
+    window.navigator.setAppBadge(newNotificationsCount);
+  }
 };
 
 const Layout = () => {

+ 28 - 16
web/src/components/Navigation.jsx

@@ -14,7 +14,6 @@ import {
   ListSubheader,
   Portal,
   Tooltip,
-  Button,
   Typography,
   Box,
   IconButton,
@@ -94,15 +93,10 @@ const NavList = (props) => {
     setSubscribeDialogKey((prev) => prev + 1);
   };
 
-  const handleRequestNotificationPermission = () => {
-    notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
-  };
-
   const handleSubscribeSubmit = (subscription) => {
     console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
     handleSubscribeReset();
     navigate(routes.forSubscription(subscription));
-    handleRequestNotificationPermission();
   };
 
   const handleAccountClick = () => {
@@ -114,19 +108,27 @@ const NavList = (props) => {
   const isPaid = account?.billing?.subscription;
   const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
   const showSubscriptionsList = props.subscriptions?.length > 0;
-  const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
+  const showNotificationPermissionDenied = notifier.denied();
+  const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
+  const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
   const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
-  const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
+
   const navListPadding =
-    showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : "";
+    showNotificationPermissionDenied ||
+    showNotificationIOSInstallRequired ||
+    showNotificationBrowserNotSupportedBox ||
+    showNotificationContextNotSupportedBox
+      ? "0"
+      : "";
 
   return (
     <>
       <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
       <List component="nav" sx={{ paddingTop: navListPadding }}>
+        {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
         {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
         {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
-        {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission} />}
+        {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}
         {!showSubscriptionsList && (
           <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
             <ListItemIcon>
@@ -344,16 +346,26 @@ const SubscriptionItem = (props) => {
   );
 };
 
-const NotificationGrantAlert = (props) => {
+const NotificationPermissionDeniedAlert = () => {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Alert severity="warning" sx={{ paddingTop: 2 }}>
+        <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
+        <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
+      </Alert>
+      <Divider />
+    </>
+  );
+};
+
+const NotificationIOSInstallRequiredAlert = () => {
   const { t } = useTranslation();
   return (
     <>
       <Alert severity="warning" sx={{ paddingTop: 2 }}>
-        <AlertTitle>{t("alert_grant_title")}</AlertTitle>
-        <Typography gutterBottom>{t("alert_grant_description")}</Typography>
-        <Button sx={{ float: "right" }} color="inherit" size="small" onClick={props.onRequestPermissionClick}>
-          {t("alert_grant_button")}
-        </Button>
+        <AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle>
+        <Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography>
       </Alert>
       <Divider />
     </>

+ 32 - 0
web/src/components/Preferences.jsx

@@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite
 import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
 import { UnauthorizedError } from "../app/errors";
 import { subscribeTopic } from "./SubscribeDialog";
+import notifier from "../app/Notifier";
 
 const maybeUpdateAccountSettings = async (payload) => {
   if (!session.exists()) {
@@ -85,6 +86,7 @@ const Notifications = () => {
         <Sound />
         <MinPriority />
         <DeleteAfter />
+        {notifier.pushSupported() && <WebPushDefaultEnabled />}
       </PrefGroup>
     </Card>
   );
@@ -232,6 +234,36 @@ const DeleteAfter = () => {
   );
 };
 
+const WebPushDefaultEnabled = () => {
+  const { t } = useTranslation();
+  const labelId = "prefWebPushDefaultEnabled";
+  const defaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled());
+  const handleChange = async (ev) => {
+    await prefs.setWebPushDefaultEnabled(ev.target.value);
+  };
+
+  // while loading
+  if (defaultEnabled == null) {
+    return null;
+  }
+
+  return (
+    <Pref
+      labelId={labelId}
+      title={t("prefs_notifications_web_push_default_title")}
+      description={t("prefs_notifications_web_push_default_description")}
+    >
+      <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
+        <Select value={defaultEnabled} onChange={handleChange} aria-labelledby={labelId}>
+          {defaultEnabled === "initial" && <MenuItem value="initial">{t("prefs_notifications_web_push_default_initial")}</MenuItem>}
+          <MenuItem value="enabled">{t("prefs_notifications_web_push_default_enabled")}</MenuItem>
+          <MenuItem value="disabled">{t("prefs_notifications_web_push_default_disabled")}</MenuItem>
+        </Select>
+      </FormControl>
+    </Pref>
+  );
+};
+
 const Users = () => {
   const { t } = useTranslation();
   const [dialogKey, setDialogKey] = useState(0);

+ 111 - 10
web/src/components/SubscribeDialog.jsx

@@ -8,17 +8,20 @@ import {
   DialogContentText,
   DialogTitle,
   Autocomplete,
-  Checkbox,
   FormControlLabel,
   FormGroup,
   useMediaQuery,
+  Switch,
+  Stack,
 } from "@mui/material";
 import { useTranslation } from "react-i18next";
+import { Warning } from "@mui/icons-material";
+import { useLiveQuery } from "dexie-react-hooks";
 import theme from "./theme";
 import api from "../app/Api";
 import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
 import userManager from "../app/UserManager";
-import subscriptionManager from "../app/SubscriptionManager";
+import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
 import poller from "../app/Poller";
 import DialogFooter from "./DialogFooter";
 import session from "../app/Session";
@@ -28,11 +31,13 @@ import ReserveTopicSelect from "./ReserveTopicSelect";
 import { AccountContext } from "./App";
 import { TopicReservedError, UnauthorizedError } from "../app/errors";
 import { ReserveLimitChip } from "./SubscriptionPopup";
+import notifier from "../app/Notifier";
+import prefs from "../app/Prefs";
 
 const publicBaseUrl = "https://ntfy.sh";
 
-export const subscribeTopic = async (baseUrl, topic) => {
-  const subscription = await subscriptionManager.add(baseUrl, topic);
+export const subscribeTopic = async (baseUrl, topic, opts) => {
+  const subscription = await subscriptionManager.add(baseUrl, topic, opts);
   if (session.exists()) {
     try {
       await accountApi.addSubscription(baseUrl, topic);
@@ -52,14 +57,29 @@ const SubscribeDialog = (props) => {
   const [showLoginPage, setShowLoginPage] = useState(false);
   const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
 
-  const handleSuccess = async () => {
+  const webPushDefaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled());
+
+  const handleSuccess = async (notificationType) => {
     console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
     const actualBaseUrl = baseUrl || config.base_url;
-    const subscription = await subscribeTopic(actualBaseUrl, topic);
+    const subscription = await subscribeTopic(actualBaseUrl, topic, {
+      notificationType,
+    });
     poller.pollInBackground(subscription); // Dangle!
+
+    // if the user hasn't changed the default web push setting yet, set it to enabled
+    if (notificationType === "background" && webPushDefaultEnabled === "initial") {
+      await prefs.setWebPushDefaultEnabled(true);
+    }
+
     props.onSuccess(subscription);
   };
 
+  // wait for liveQuery load
+  if (webPushDefaultEnabled === undefined) {
+    return <></>;
+  }
+
   return (
     <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
       {!showLoginPage && (
@@ -72,6 +92,7 @@ const SubscribeDialog = (props) => {
           onCancel={props.onCancel}
           onNeedsLogin={() => setShowLoginPage(true)}
           onSuccess={handleSuccess}
+          webPushDefaultEnabled={webPushDefaultEnabled}
         />
       )}
       {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
@@ -79,6 +100,22 @@ const SubscribeDialog = (props) => {
   );
 };
 
+const browserNotificationsSupported = notifier.supported();
+const pushNotificationsSupported = notifier.pushSupported();
+const iosInstallRequired = notifier.iosSupportedButInstallRequired();
+
+const getNotificationTypeFromToggles = (browserNotificationsEnabled, backgroundNotificationsEnabled) => {
+  if (backgroundNotificationsEnabled) {
+    return NotificationType.BACKGROUND;
+  }
+
+  if (browserNotificationsEnabled) {
+    return NotificationType.BROWSER;
+  }
+
+  return NotificationType.SOUND;
+};
+
 const SubscribePage = (props) => {
   const { t } = useTranslation();
   const { account } = useContext(AccountContext);
@@ -96,6 +133,30 @@ const SubscribePage = (props) => {
   const reserveTopicEnabled =
     session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
 
+  // load initial value, but update it in `handleBrowserNotificationsChanged`
+  // if we interact with the API and therefore possibly change it (from default -> denied)
+  const [notificationsExplicitlyDenied, setNotificationsExplicitlyDenied] = useState(notifier.denied());
+  // default to on if notifications are already granted
+  const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = useState(notifier.granted());
+  const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(props.webPushDefaultEnabled === "enabled");
+
+  const handleBrowserNotificationsChanged = async (e) => {
+    if (e.target.checked && (await notifier.maybeRequestPermission())) {
+      setBrowserNotificationsEnabled(true);
+      if (props.webPushDefaultEnabled === "enabled") {
+        setBackgroundNotificationsEnabled(true);
+      }
+    } else {
+      setNotificationsExplicitlyDenied(notifier.denied());
+      setBrowserNotificationsEnabled(false);
+      setBackgroundNotificationsEnabled(false);
+    }
+  };
+
+  const handleBackgroundNotificationsChanged = (e) => {
+    setBackgroundNotificationsEnabled(e.target.checked);
+  };
+
   const handleSubscribe = async () => {
     const user = await userManager.get(baseUrl); // May be undefined
     const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
@@ -133,12 +194,15 @@ const SubscribePage = (props) => {
     }
 
     console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
-    props.onSuccess();
+    props.onSuccess(getNotificationTypeFromToggles(browserNotificationsEnabled, backgroundNotificationsEnabled));
   };
 
   const handleUseAnotherChanged = (e) => {
     props.setBaseUrl("");
     setAnotherServerVisible(e.target.checked);
+    if (e.target.checked) {
+      setBackgroundNotificationsEnabled(false);
+    }
   };
 
   const subscribeButtonEnabled = (() => {
@@ -193,8 +257,7 @@ const SubscribePage = (props) => {
             <FormControlLabel
               variant="standard"
               control={
-                <Checkbox
-                  fullWidth
+                <Switch
                   disabled={!reserveTopicEnabled}
                   checked={reserveTopicVisible}
                   onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
@@ -217,8 +280,9 @@ const SubscribePage = (props) => {
           <FormGroup>
             <FormControlLabel
               control={
-                <Checkbox
+                <Switch
                   onChange={handleUseAnotherChanged}
+                  checked={anotherServerVisible}
                   inputProps={{
                     "aria-label": t("subscribe_dialog_subscribe_use_another_label"),
                   }}
@@ -244,6 +308,43 @@ const SubscribePage = (props) => {
             )}
           </FormGroup>
         )}
+        {browserNotificationsSupported && (
+          <FormGroup>
+            <FormControlLabel
+              control={
+                <Switch
+                  onChange={handleBrowserNotificationsChanged}
+                  checked={browserNotificationsEnabled}
+                  disabled={notificationsExplicitlyDenied}
+                  inputProps={{
+                    "aria-label": t("subscribe_dialog_subscribe_enable_browser_notifications_label"),
+                  }}
+                />
+              }
+              label={
+                <Stack direction="row" gap={1} alignItems="center">
+                  {t("subscribe_dialog_subscribe_enable_browser_notifications_label")}
+                  {notificationsExplicitlyDenied && <Warning />}
+                </Stack>
+              }
+            />
+            {pushNotificationsSupported && !anotherServerVisible && browserNotificationsEnabled && (
+              <FormControlLabel
+                control={
+                  <Switch
+                    onChange={handleBackgroundNotificationsChanged}
+                    checked={backgroundNotificationsEnabled}
+                    disabled={iosInstallRequired}
+                    inputProps={{
+                      "aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
+                    }}
+                  />
+                }
+                label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
+              />
+            )}
+          </FormGroup>
+        )}
       </DialogContent>
       <DialogFooter status={error}>
         <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>

+ 156 - 12
web/src/components/SubscriptionPopup.jsx

@@ -14,12 +14,26 @@ import {
   useMediaQuery,
   MenuItem,
   IconButton,
+  ListItemIcon,
+  ListItemText,
+  Divider,
 } from "@mui/material";
 import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
-import { Clear } from "@mui/icons-material";
+import {
+  Check,
+  Clear,
+  ClearAll,
+  Edit,
+  EnhancedEncryption,
+  Lock,
+  LockOpen,
+  NotificationsOff,
+  RemoveCircle,
+  Send,
+} from "@mui/icons-material";
 import theme from "./theme";
-import subscriptionManager from "../app/SubscriptionManager";
+import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
 import DialogFooter from "./DialogFooter";
 import accountApi, { Role } from "../app/AccountApi";
 import session from "../app/Session";
@@ -30,6 +44,7 @@ import api from "../app/Api";
 import { AccountContext } from "./App";
 import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
 import { UnauthorizedError } from "../app/errors";
+import notifier from "../app/Notifier";
 
 export const SubscriptionPopup = (props) => {
   const { t } = useTranslation();
@@ -70,8 +85,7 @@ export const SubscriptionPopup = (props) => {
   };
 
   const handleSendTestMessage = async () => {
-    const { baseUrl } = props.subscription;
-    const { topic } = props.subscription;
+    const { baseUrl, topic } = props.subscription;
     const tags = shuffle([
       "grinning",
       "octopus",
@@ -133,7 +147,7 @@ export const SubscriptionPopup = (props) => {
 
   const handleUnsubscribe = async () => {
     console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
-    await subscriptionManager.remove(props.subscription.id);
+    await subscriptionManager.remove(props.subscription);
     if (session.exists() && !subscription.internal) {
       try {
         await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
@@ -155,19 +169,72 @@ export const SubscriptionPopup = (props) => {
   return (
     <>
       <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
-        <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
-        {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
+        <NotificationToggle subscription={subscription} />
+        <Divider />
+        <MenuItem onClick={handleChangeDisplayName}>
+          <ListItemIcon>
+            <Edit fontSize="small" />
+          </ListItemIcon>
+
+          {t("action_bar_change_display_name")}
+        </MenuItem>
+        {showReservationAdd && (
+          <MenuItem onClick={handleReserveAdd}>
+            <ListItemIcon>
+              <Lock fontSize="small" />
+            </ListItemIcon>
+            {t("action_bar_reservation_add")}
+          </MenuItem>
+        )}
         {showReservationAddDisabled && (
           <MenuItem sx={{ cursor: "default" }}>
+            <ListItemIcon>
+              <Lock fontSize="small" color="disabled" />
+            </ListItemIcon>
+
             <span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
             <ReserveLimitChip />
           </MenuItem>
         )}
-        {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
-        {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
-        <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
-        <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
-        <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
+        {showReservationEdit && (
+          <MenuItem onClick={handleReserveEdit}>
+            <ListItemIcon>
+              <EnhancedEncryption fontSize="small" />
+            </ListItemIcon>
+
+            {t("action_bar_reservation_edit")}
+          </MenuItem>
+        )}
+        {showReservationDelete && (
+          <MenuItem onClick={handleReserveDelete}>
+            <ListItemIcon>
+              <LockOpen fontSize="small" />
+            </ListItemIcon>
+
+            {t("action_bar_reservation_delete")}
+          </MenuItem>
+        )}
+        <MenuItem onClick={handleSendTestMessage}>
+          <ListItemIcon>
+            <Send fontSize="small" />
+          </ListItemIcon>
+
+          {t("action_bar_send_test_notification")}
+        </MenuItem>
+        <MenuItem onClick={handleClearAll}>
+          <ListItemIcon>
+            <ClearAll fontSize="small" />
+          </ListItemIcon>
+
+          {t("action_bar_clear_notifications")}
+        </MenuItem>
+        <MenuItem onClick={handleUnsubscribe}>
+          <ListItemIcon>
+            <RemoveCircle fontSize="small" />
+          </ListItemIcon>
+
+          {t("action_bar_unsubscribe")}
+        </MenuItem>
       </PopupMenu>
       <Portal>
         <Snackbar
@@ -267,6 +334,83 @@ const DisplayNameDialog = (props) => {
   );
 };
 
+const getNotificationType = (subscription) => {
+  if (subscription.mutedUntil === 1) {
+    return "muted";
+  }
+
+  return subscription.notificationType ?? NotificationType.BROWSER;
+};
+
+const checkedItem = (
+  <ListItemIcon>
+    <Check />
+  </ListItemIcon>
+);
+
+const NotificationToggle = ({ subscription }) => {
+  const { t } = useTranslation();
+  const type = getNotificationType(subscription);
+
+  const handleChange = async (newType) => {
+    try {
+      if (newType !== NotificationType.SOUND && !(await notifier.maybeRequestPermission())) {
+        return;
+      }
+
+      await subscriptionManager.setNotificationType(subscription, newType);
+    } catch (e) {
+      console.error("[NotificationToggle] Error setting notification type", e);
+    }
+  };
+
+  const unmute = async () => {
+    await subscriptionManager.setMutedUntil(subscription.id, 0);
+  };
+
+  if (type === "muted") {
+    return (
+      <MenuItem onClick={unmute}>
+        <ListItemIcon>
+          <NotificationsOff />
+        </ListItemIcon>
+        {t("notification_toggle_unmute")}
+      </MenuItem>
+    );
+  }
+
+  return (
+    <>
+      <MenuItem>
+        {type === NotificationType.SOUND && checkedItem}
+        <ListItemText inset={type !== NotificationType.SOUND} onClick={() => handleChange(NotificationType.SOUND)}>
+          {t("notification_toggle_sound")}
+        </ListItemText>
+      </MenuItem>
+      {!notifier.denied() && !notifier.iosSupportedButInstallRequired() && (
+        <>
+          {notifier.supported() && (
+            <MenuItem>
+              {type === NotificationType.BROWSER && checkedItem}
+              <ListItemText inset={type !== NotificationType.BROWSER} onClick={() => handleChange(NotificationType.BROWSER)}>
+                {t("notification_toggle_browser")}
+              </ListItemText>
+            </MenuItem>
+          )}
+          {notifier.pushSupported() && (
+            <MenuItem>
+              {type === NotificationType.BACKGROUND && checkedItem}
+              <ListItemText inset={type !== NotificationType.BACKGROUND} onClick={() => handleChange(NotificationType.BACKGROUND)}>
+                {t("notification_toggle_background")}
+              </ListItemText>
+            </MenuItem>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
 export const ReserveLimitChip = () => {
   const { account } = useContext(AccountContext);
   if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {

+ 26 - 7
web/src/components/hooks.js

@@ -2,7 +2,6 @@ import { useNavigate, useParams } from "react-router-dom";
 import { useEffect, useState } from "react";
 import subscriptionManager from "../app/SubscriptionManager";
 import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
-import notifier from "../app/Notifier";
 import routes from "./routes";
 import connectionManager from "../app/ConnectionManager";
 import poller from "../app/Poller";
@@ -10,6 +9,7 @@ import pruner from "../app/Pruner";
 import session from "../app/Session";
 import accountApi from "../app/AccountApi";
 import { UnauthorizedError } from "../app/errors";
+import webPushWorker from "../app/WebPushWorker";
 
 /**
  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -41,7 +41,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
         const added = await subscriptionManager.addNotification(subscriptionId, notification);
         if (added) {
           const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
-          await notifier.notify(subscriptionId, notification, defaultClickAction);
+          await subscriptionManager.notify(subscriptionId, notification, defaultClickAction);
         }
       };
 
@@ -61,7 +61,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
         }
       };
 
-      connectionManager.registerStateListener(subscriptionManager.updateState);
+      connectionManager.registerStateListener((id, state) => subscriptionManager.updateState(id, state));
       connectionManager.registerMessageListener(handleMessage);
 
       return () => {
@@ -79,7 +79,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
     if (!account || !account.sync_topic) {
       return;
     }
-    subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
+    subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!
   }, [account]);
 
   // When subscriptions or users change, refresh the connections
@@ -129,11 +129,30 @@ export const useAutoSubscribe = (subscriptions, selected) => {
  * and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
  * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
  */
+
+const stopWorkers = () => {
+  poller.stopWorker();
+  pruner.stopWorker();
+  accountApi.stopWorker();
+};
+
+const startWorkers = () => {
+  poller.startWorker();
+  pruner.startWorker();
+  accountApi.startWorker();
+};
+
 export const useBackgroundProcesses = () => {
   useEffect(() => {
-    poller.startWorker();
-    pruner.startWorker();
-    accountApi.startWorker();
+    console.log("[useBackgroundProcesses] mounting");
+    startWorkers();
+    webPushWorker.startWorker();
+
+    return () => {
+      console.log("[useBackgroundProcesses] unloading");
+      stopWorkers();
+      webPushWorker.stopWorker();
+    };
   }, []);
 };
 

+ 60 - 1
web/vite.config.js

@@ -1,14 +1,73 @@
 /* eslint-disable import/no-extraneous-dependencies */
 import { defineConfig } from "vite";
 import react from "@vitejs/plugin-react";
+import { VitePWA } from "vite-plugin-pwa";
+
+// please look at develop.md for how to run your browser
+// in a mode allowing insecure service worker testing
+// this turns on:
+// - the service worker in dev mode
+// - turns off automatically opening the browser
+const enableLocalPWATesting = process.env.ENABLE_DEV_PWA;
 
 export default defineConfig(() => ({
   build: {
     outDir: "build",
     assetsDir: "static/media",
+    sourcemap: true,
   },
   server: {
     port: 3000,
+    open: !enableLocalPWATesting,
   },
-  plugins: [react()],
+  plugins: [
+    react(),
+    VitePWA({
+      registerType: "autoUpdate",
+      injectRegister: "inline",
+      strategies: "injectManifest",
+      devOptions: {
+        enabled: enableLocalPWATesting,
+        /* when using generateSW the PWA plugin will switch to classic */
+        type: "module",
+        navigateFallback: "index.html",
+      },
+      injectManifest: {
+        globPatterns: ["**/*.{js,css,html,mp3,png,svg,json}"],
+        globIgnores: ["config.js"],
+        manifestTransforms: [
+          (entries) => ({
+            manifest: entries.map((entry) =>
+              entry.url === "index.html"
+                ? {
+                    ...entry,
+                    url: "/",
+                  }
+                : entry
+            ),
+          }),
+        ],
+      },
+      manifest: {
+        name: "ntfy web",
+        short_name: "ntfy",
+        description:
+          "ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy.",
+        theme_color: "#317f6f",
+        start_url: "/",
+        icons: [
+          {
+            src: "/static/images/pwa-192x192.png",
+            sizes: "192x192",
+            type: "image/png",
+          },
+          {
+            src: "/static/images/pwa-512x512.png",
+            sizes: "512x512",
+            type: "image/png",
+          },
+        ],
+      },
+    }),
+  ],
 }));

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini