diff --git a/.gitignore b/.gitignore
index f695607e2..b60c9b231 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ secrets/
node_modules/
.DS_Store
__pycache__
+web/dev-dist/
\ No newline at end of file
diff --git a/README.md b/README.md
index cff77e3d5..59047eed6 100644
--- a/README.md
+++ b/README.md
@@ -179,3 +179,4 @@ Third party libraries and resources:
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
+* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
diff --git a/cmd/serve.go b/cmd/serve.go
index 5d5381bf2..8c88e4d62 100644
--- a/cmd/serve.go
+++ b/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.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-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_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"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup-queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
)
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")
+ webPushPrivateKey := c.String("web-push-private-key")
+ webPushPublicKey := c.String("web-push-public-key")
+ webPushFile := c.String("web-push-file")
+ webPushEmailAddress := c.String("web-push-email-address")
+ webPushStartupQueries := c.String("web-push-startup-queries")
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 webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
+ return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush 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.WebPushPrivateKey = webPushPrivateKey
+ conf.WebPushPublicKey = webPushPublicKey
+ conf.WebPushFile = webPushFile
+ conf.WebPushEmailAddress = webPushEmailAddress
+ conf.WebPushStartupQueries = webPushStartupQueries
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
diff --git a/cmd/webpush.go b/cmd/webpush.go
new file mode 100644
index 000000000..ec66f083b
--- /dev/null
+++ b/cmd/webpush.go
@@ -0,0 +1,48 @@
+//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: "webpush",
+ Usage: "Generate keys, in the future manage web push subscriptions",
+ UsageText: "ntfy webpush [keys]",
+ Category: categoryServer,
+
+ Subcommands: []*cli.Command{
+ {
+ Action: generateWebPushKeys,
+ Name: "keys",
+ Usage: "Generate VAPID keys to enable browser background push notifications",
+ UsageText: "ntfy webpush keys",
+ Category: categoryServer,
+ },
+ },
+}
+
+func generateWebPushKeys(c *cli.Context) error {
+ privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
+
+web-push-public-key: %s
+web-push-private-key: %s
+web-push-file: /var/cache/ntfy/webpush.db # or similar
+web-push-email-address:
+
+See https://ntfy.sh/docs/config/#web-push for details.
+`, publicKey, privateKey)
+ return err
+}
diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go
new file mode 100644
index 000000000..1b3647011
--- /dev/null
+++ b/cmd/webpush_test.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/urfave/cli/v2"
+ "heckel.io/ntfy/server"
+)
+
+func TestCLI_WebPush_GenerateKeys(t *testing.T) {
+ app, _, _, stderr := newTestApp()
+ require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
+ require.Contains(t, stderr.String(), "Web Push keys generated.")
+}
+
+func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
+ webPushArgs := []string{
+ "ntfy",
+ "--log-level=ERROR",
+ "webpush",
+ }
+ return app.Run(append(webPushArgs, args...))
+}
diff --git a/docs/config.md b/docs/config.md
index df1f2cd62..9af799924 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -789,6 +789,57 @@ Note that the self-hosted server literally sends the message `New message` for e
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
it'll show `New message` as a popup.
+## Web Push
+[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))
+allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed.
+When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the
+user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then
+forward it to the browser.
+
+To configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`),
+a database to keep track of the browser's subscriptions, and an admin email address (you):
+
+- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
+- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
+- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
+- `web-push-startup-queries` is an optional list of queries to run on startup`
+
+Limitations:
+
+- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
+ certificate is required, as service workers will not run on origins with untrusted certificates.
+
+- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This
+ is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin.
+
+To configure VAPID keys, first generate them:
+
+```sh
+$ ntfy webpush keys
+Web Push keys generated.
+...
+```
+
+Then copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments:
+
+```yaml
+web-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+web-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890
+web-push-file: /var/cache/ntfy/webpush.db
+web-push-email-address: sysadmin@example.com
+```
+
+The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days,
+and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
+subscriptions are also removed automatically.
+
+The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
+file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
+
+Changing your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and
+if you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission.
+
## Tiers
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
@@ -1285,13 +1336,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-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate |
+| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate |
+| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
+| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
+| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h.
The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k.
## Command line options
```
-$ ntfy serve --help
NAME:
ntfy serve - Run the ntfy server
@@ -1321,8 +1376,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 +1398,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 +1411,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 +1425,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-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-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_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]
+ --web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
+ --help, -h show help
```
-
diff --git a/docs/develop.md b/docs/develop.md
index baab3f3a1..05b557739 100644
--- a/docs/develop.md
+++ b/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,41 @@ $ cd web
$ npm start
```
+### Testing Web Push locally
+
+Reference:
+
+#### With the dev servers
+
+1. Get web push keys `go run main.go webpush keys`
+
+2. Run the server with web push enabled
+
+ ```sh
+ go run main.go \
+ --log-level debug \
+ serve \
+ --web-push-public-key KEY \
+ --web-push-private-key KEY \
+ --web-push-email-address \
+ --web-push-file=/tmp/webpush.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`.
+
+ - Set the `web_push_public_key` correctly.
+
+4. Run `npm run start`
+
+#### With a built package
+
+1. Run `make web-build`
+
+2. Run the server (step 2 above)
+
+3. Open
### 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:
diff --git a/docs/releases.md b/docs/releases.md
index 03b434ce9..6b2e5a939 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1222,6 +1222,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
### ntfy server v2.6.0 (UNRELEASED)
+**Features:**
+
+* The web app now supports web push, and is installable on Chrome, Edge, Android, and iOS. Look at the [web app docs](https://docs.ntfy.sh/subscribe/web/) for more information ([#751](https://github.com/binwiederhier/ntfy/pull/751), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
**Bug fixes:**
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
diff --git a/docs/static/img/pwa-badge.png b/docs/static/img/pwa-badge.png
new file mode 100644
index 000000000..fc11cb63d
Binary files /dev/null and b/docs/static/img/pwa-badge.png differ
diff --git a/docs/static/img/pwa-install.png b/docs/static/img/pwa-install.png
new file mode 100644
index 000000000..e7397add6
Binary files /dev/null and b/docs/static/img/pwa-install.png differ
diff --git a/docs/static/img/pwa.png b/docs/static/img/pwa.png
new file mode 100644
index 000000000..1c72ed54f
Binary files /dev/null and b/docs/static/img/pwa.png differ
diff --git a/docs/static/img/web-pin.png b/docs/static/img/web-pin.png
deleted file mode 100644
index 3312a50fa..000000000
Binary files a/docs/static/img/web-pin.png and /dev/null differ
diff --git a/docs/static/img/web-subscribe.png b/docs/static/img/web-subscribe.png
index f60a86584..ccbd04930 100644
Binary files a/docs/static/img/web-subscribe.png and b/docs/static/img/web-subscribe.png differ
diff --git a/docs/subscribe/desktop.md b/docs/subscribe/desktop.md
new file mode 100644
index 000000000..8d97571cd
--- /dev/null
+++ b/docs/subscribe/desktop.md
@@ -0,0 +1,18 @@
+# Using the web app as an installed web app
+While ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA)
+and thus can be installed on both desktop and mobile devices. This gives it its own launcher (e.g. shortcut on Windows, app on
+macOS, launcher shortcut on Linux), own window, push notifications, and an app badge with the unread notification count.
+
+To install and register the web app in your operating system, click the "install app" icon in your browser (usually next to the
+address bar). To receive background notifications, **either the browser or the installed web app must be open**.
+
+
+
+Web app installation is supported on Chrome and Edge on desktop, as well as Chrome on Android and Safari on iOS.
+Look at the [compatibility table](https://caniuse.com/web-app-manifest) for more info.
+
+
+
+
+
+
diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md
index 5c2672f07..1508e99f7 100644
--- a/docs/subscribe/web.md
+++ b/docs/subscribe/web.md
@@ -1,27 +1,51 @@
-# Subscribe from the Web UI
-You can use the Web UI to subscribe to topics as well. If you do, and you keep the website open, **notifications will
-pop up as desktop notifications**. Simply type in the topic name and click the *Subscribe* button. The browser will
-keep a connection open and listen for incoming notifications.
+# Subscribe from the web app
+The web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app).
+To subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic
+will appear in the web app, and pop up as a notification.**
+
+
+
+
+## Publish messages
To learn how to send messages, check out the [publishing page](../publish.md).
-
-To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
-is to pin the tab so that it's always open, but sort of out of the way:
-
-
-
+## Topic reservations
If topic reservations are enabled, you can claim ownership over topics and define access to it:
+
+## Background notifications
+While subscribing, you have the option to enable background notifications on supported browsers (see "Settings" tab).
+
+**If background notifications are off (default):** This requires an active ntfy tab to be open to receive notifications.
+These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser
+is allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications,
+**pinning the ntfy tab on your browser** is a good solution to leave it running.
+
+**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active
+ntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the
+same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself.
+
+If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them
+by opening the app again, and will get a warning notification before they are paused.
+
+| Browser | Platform | Browser Running | Browser Not Running | Restrictions |
+|---------|----------|-----------------|---------------------|---------------------------------------------------------|
+| Chrome | Desktop | ✅ | ❌ | |
+| Firefox | Desktop | ✅ | ❌ | |
+| Edge | Desktop | ✅ | ❌ | |
+| Opera | Desktop | ✅ | ❌ | |
+| Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura |
+| Chrome | Android | ✅ | ✅ | |
+| Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen |
+
+(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info)
diff --git a/go.mod b/go.mod
index 19af7ba56..96ffff98f 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.11.0
+ github.com/SherClockHolmes/webpush-go v1.2.0
github.com/prometheus/client_golang v1.15.1
github.com/stripe/stripe-go/v74 v74.21.0
)
@@ -43,6 +44,7 @@ require (
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
diff --git a/go.sum b/go.sum
index 999fc8acd..d8e78b86d 100644
--- a/go.sum
+++ b/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=
diff --git a/mkdocs.yml b/mkdocs.yml
index 4a7db3665..76d39299c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -82,6 +82,7 @@ nav:
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md
+ - "From the Desktop": subscribe/desktop.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
diff --git a/server/config.go b/server/config.go
index a876926e4..9815aa88f 100644
--- a/server/config.go
+++ b/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)
@@ -22,6 +23,12 @@ const (
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
)
+// Defines default Web Push settings
+const (
+ DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour
+ DefaultWebPushExpiryDuration = 9 * 24 * time.Hour
+)
+
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall
@@ -146,6 +153,13 @@ type Config struct {
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
+ WebPushPrivateKey string
+ WebPushPublicKey string
+ WebPushFile string
+ WebPushEmailAddress string
+ WebPushStartupQueries string
+ WebPushExpiryDuration time.Duration
+ WebPushExpiryWarningDuration time.Duration
}
// NewConfig instantiates a default new server config
@@ -227,5 +241,11 @@ func NewConfig() *Config {
EnableReservations: false,
AccessControlAllowOrigin: "*",
Version: "",
+ WebPushPrivateKey: "",
+ WebPushPublicKey: "",
+ WebPushFile: "",
+ WebPushEmailAddress: "",
+ WebPushExpiryDuration: DefaultWebPushExpiryDuration,
+ WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
}
}
diff --git a/server/errors.go b/server/errors.go
index eee916b56..27ba3df0d 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -114,6 +114,9 @@ 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}
+ errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
+ errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", 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}
@@ -138,5 +141,6 @@ var (
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
+ errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
)
diff --git a/server/log.go b/server/log.go
index c638ed972..978d0593c 100644
--- a/server/log.go
+++ b/server/log.go
@@ -29,6 +29,7 @@ const (
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
+ tagWebPush = "webpush"
)
var (
diff --git a/server/message_cache.go b/server/message_cache.go
index 1d7302afb..140271fef 100644
--- a/server/message_cache.go
+++ b/server/message_cache.go
@@ -270,7 +270,7 @@ func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration
if err != nil {
return nil, err
}
- if err := setupDB(db, startupQueries, cacheDuration); err != nil {
+ if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
@@ -749,7 +749,7 @@ func (c *messageCache) Close() error {
return c.db.Close()
}
-func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
+func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
diff --git a/server/server.go b/server/server.go
index d2fac01f7..2162f47d8 100644
--- a/server/server.go
+++ b/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,6 +25,14 @@ 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"
)
// Server is the main server, providing the UI and API for ntfy
@@ -52,6 +53,7 @@ type Server struct {
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
+ webPush *webPushStore // 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!)
@@ -76,11 +78,15 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
+ webManifestPath = "/manifest.webmanifest"
+ webRootHTMLPath = "/app.html"
+ webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiStatsPath = "/v1/stats"
+ apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
@@ -151,6 +157,13 @@ func New(conf *Config) (*Server, error) {
if err != nil {
return nil, err
}
+ var webPush *webPushStore
+ if conf.WebPushPublicKey != "" {
+ webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
+ if err != nil {
+ return nil, err
+ }
+ }
topics, err := messageCache.Topics()
if err != nil {
return nil, err
@@ -190,6 +203,7 @@ func New(conf *Config) (*Server, error) {
s := &Server{
config: conf,
messageCache: messageCache,
+ webPush: webPush,
fileCache: fileCache,
firebaseClient: firebaseClient,
smtpSender: mailer,
@@ -342,6 +356,9 @@ func (s *Server) closeDatabases() {
s.userManager.Close()
}
s.messageCache.Close()
+ if s.webPush != nil {
+ s.webPush.Close()
+ }
}
// handle is the main entry point for all HTTP requests
@@ -416,6 +433,8 @@ 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 == apiUsersPath {
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
@@ -470,6 +489,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
+ } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
+ return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
+ } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
+ return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushDelete))(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@@ -478,7 +501,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
- } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
+ } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@@ -552,7 +575,9 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableReservations: s.config.EnableReservations,
+ EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
+ WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
}
b, err := json.MarshalIndent(response, "", " ")
@@ -564,6 +589,26 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
return err
}
+// handleWebManifest serves the web app manifest for the progressive web app (PWA)
+func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+ response := &webManifestResponse{
+ Name: "ntfy web",
+ Description: "ntfy lets you send push notifications via scripts from any computer or phone",
+ ShortName: "ntfy",
+ Scope: "/",
+ StartURL: s.config.WebRoot,
+ Display: "standalone",
+ BackgroundColor: "#ffffff",
+ ThemeColor: "#317f6f",
+ Icons: []*webManifestIcon{
+ {SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
+ {SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
+ },
+ }
+ w.Header().Set("Content-Type", "application/manifest+json")
+ return s.writeJSON(w, response)
+}
+
// 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 +808,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.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
} else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@@ -1692,6 +1740,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
+ if s.config.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
if err := s.messageCache.MarkPublished(m); err != nil {
return err
}
diff --git a/server/server.yml b/server/server.yml
index 9c7972e95..6b2fc989a 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -144,6 +144,27 @@
# smtp-server-domain:
# smtp-server-addr-prefix:
+# Web Push support (background notifications for browsers)
+#
+# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users
+# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
+# endpoint, which will then forward it to the browser.
+#
+# You must configure web-push-public/private key, web-push-file, and web-push-email-address below to enable Web Push.
+# Run "ntfy webpush keys" to generate the keys.
+#
+# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
+# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
+# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com`
+# - web-push-startup-queries is an optional list of queries to run on startup`
+#
+# web-push-public-key:
+# web-push-private-key:
+# web-push-file:
+# web-push-email-address:
+# web-push-startup-queries:
+
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
diff --git a/server/server_account.go b/server/server_account.go
index 6e6a6864f..f26cc2ff7 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -170,6 +170,11 @@ 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.webPush != nil && u.ID != "" {
+ if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); 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 {
diff --git a/server/server_manager.go b/server/server_manager.go
index 52e3621ef..66d449dee 100644
--- a/server/server_manager.go
+++ b/server/server_manager.go
@@ -15,6 +15,7 @@ func (s *Server) execManager() {
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
+ s.pruneAndNotifyWebPushSubscriptions()
// Message count per topic
var messagesCached int
diff --git a/server/server_middleware.go b/server/server_middleware.go
index 7aea45a39..b9d1bb88d 100644
--- a/server/server_middleware.go
+++ b/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.WebPushPublicKey == "" {
+ 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 {
diff --git a/server/server_test.go b/server/server_test.go
index d7c4a7c67..d4f266ea7 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -22,6 +22,7 @@ import (
"testing"
"time"
+ "github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
@@ -238,6 +239,15 @@ 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", "/app.html", "", 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 +260,16 @@ 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)
+
+ rr = request(t, s2, "GET", "/app.html", "", nil)
+ require.Equal(t, 200, rr.Code)
}
func TestServer_PublishLargeMessage(t *testing.T) {
@@ -2591,19 +2611,33 @@ func newTestConfig(t *testing.T) *Config {
return conf
}
-func newTestConfigWithAuthFile(t *testing.T) *Config {
- conf := newTestConfig(t)
+func configureAuth(t *testing.T, conf *Config) *Config {
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot
return conf
}
+func newTestConfigWithAuthFile(t *testing.T) *Config {
+ conf := newTestConfig(t)
+ conf = configureAuth(t, conf)
+ return conf
+}
+
+func newTestConfigWithWebPush(t *testing.T) *Config {
+ conf := newTestConfig(t)
+ privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+ require.Nil(t, err)
+ conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
+ conf.WebPushEmailAddress = "testing@example.com"
+ conf.WebPushPrivateKey = privateKey
+ conf.WebPushPublicKey = publicKey
+ return conf
+}
+
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
- if err != nil {
- t.Fatal(err)
- }
+ require.Nil(t, err)
return server
}
diff --git a/server/server_webpush.go b/server/server_webpush.go
new file mode 100644
index 000000000..bb0f5408b
--- /dev/null
+++ b/server/server_webpush.go
@@ -0,0 +1,171 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/SherClockHolmes/webpush-go"
+ "heckel.io/ntfy/log"
+ "heckel.io/ntfy/user"
+)
+
+const (
+ webPushTopicSubscribeLimit = 50
+)
+
+var (
+ webPushAllowedEndpointsPatterns = []string{
+ "https://*.google.com/",
+ "https://*.googleapis.com/",
+ "https://*.mozilla.com/",
+ "https://*.mozaws.net/",
+ "https://*.windows.com/",
+ "https://*.microsoft.com/",
+ "https://*.apple.com/",
+ }
+ webPushAllowedEndpointsRegex *regexp.Regexp
+)
+
+func init() {
+ for i, pattern := range webPushAllowedEndpointsPatterns {
+ webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+")
+ }
+ allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|"))
+ webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)
+}
+
+func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" {
+ return errHTTPBadRequestWebPushSubscriptionInvalid
+ } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {
+ return errHTTPBadRequestWebPushEndpointUnknown
+ } else if len(req.Topics) > webPushTopicSubscribeLimit {
+ return errHTTPBadRequestWebPushTopicCountTooHigh
+ }
+ topics, err := s.topicsFromIDs(req.Topics...)
+ if err != nil {
+ return err
+ }
+ if s.userManager != nil {
+ u := v.User()
+ for _, t := range topics {
+ if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {
+ logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
+ return errHTTPForbidden.With(t)
+ }
+ }
+ }
+ if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
+ req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil || req.Endpoint == "" {
+ return errHTTPBadRequestWebPushSubscriptionInvalid
+ }
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(req.Endpoint); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
+ subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
+ if err != nil {
+ logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
+ return
+ }
+ log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
+ payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
+ if err != nil {
+ log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
+ return
+ }
+ for _, subscription := range subscriptions {
+ if err := s.sendWebPushNotification(subscription, payload, v, m); err != nil {
+ log.Tag(tagWebPush).Err(err).With(v, m, subscription).Warn("Unable to publish web push message")
+ }
+ }
+}
+
+func (s *Server) pruneAndNotifyWebPushSubscriptions() {
+ if s.config.WebPushPublicKey == "" {
+ return
+ }
+ go func() {
+ if err := s.pruneAndNotifyWebPushSubscriptionsInternal(); err != nil {
+ log.Tag(tagWebPush).Err(err).Warn("Unable to prune or notify web push subscriptions")
+ }
+ }()
+}
+
+func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
+ // Expire old subscriptions
+ if err := s.webPush.RemoveExpiredSubscriptions(s.config.WebPushExpiryDuration); err != nil {
+ return err
+ }
+ // Notify subscriptions that will expire soon
+ subscriptions, err := s.webPush.SubscriptionsExpiring(s.config.WebPushExpiryWarningDuration)
+ if err != nil {
+ return err
+ } else if len(subscriptions) == 0 {
+ return nil
+ }
+ payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload())
+ if err != nil {
+ return err
+ }
+ warningSent := make([]*webPushSubscription, 0)
+ for _, subscription := range subscriptions {
+ if err := s.sendWebPushNotification(subscription, payload); err != nil {
+ log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
+ continue
+ }
+ warningSent = append(warningSent, subscription)
+ }
+ if err := s.webPush.MarkExpiryWarningSent(warningSent); err != nil {
+ return err
+ }
+ log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions))
+ return nil
+}
+
+func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
+ payload := &webpush.Subscription{
+ Endpoint: sub.Endpoint,
+ Keys: webpush.Keys{
+ Auth: sub.Auth,
+ P256dh: sub.P256dh,
+ },
+ }
+ resp, err := webpush.SendNotification(message, payload, &webpush.Options{
+ Subscriber: s.config.WebPushEmailAddress,
+ VAPIDPublicKey: s.config.WebPushPublicKey,
+ VAPIDPrivateKey: s.config.WebPushPrivateKey,
+ Urgency: webpush.UrgencyHigh, // iOS requires this to ensure delivery
+ TTL: int(s.config.CacheDuration.Seconds()),
+ })
+ if err != nil {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Err(err).Debug("Unable to publish web push message, removing endpoint")
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
+ return err
+ }
+ return err
+ }
+ if (resp.StatusCode < 200 || resp.StatusCode > 299) && resp.StatusCode != 429 {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Field("response_code", resp.StatusCode).Debug("Unable to publish web push message, unexpected response")
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
+ return err
+ }
+ return errHTTPInternalErrorWebPushUnableToPublish.With(sub).With(contexters...)
+ }
+ return nil
+}
diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go
new file mode 100644
index 000000000..c0db79c66
--- /dev/null
+++ b/server/server_webpush_test.go
@@ -0,0 +1,256 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/stretchr/testify/require"
+ "heckel.io/ntfy/user"
+ "heckel.io/ntfy/util"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+const (
+ testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
+)
+
+func TestServer_WebPush_Disabled(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_WebPush_TopicAdd(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+
+ require.Len(t, subs, 1)
+ require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
+ require.Equal(t, subs[0].P256dh, "p256dh-key")
+ require.Equal(t, subs[0].Auth, "auth-key")
+ require.Equal(t, subs[0].UserID, "")
+}
+
+func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
+}
+
+func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ topicList := make([]string, 51)
+ for i := range topicList {
+ topicList[i] = util.RandomString(5)
+ }
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
+}
+
+func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ addSubscription(t, s, testWebPushEndpoint, "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_Delete(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ addSubscription(t, s, testWebPushEndpoint, "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ config.AuthDefault = user.PermissionDenyAll
+ s := newTestServer(t, config)
+
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+ require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
+}
+
+func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ config.AuthDefault = user.PermissionDenyAll
+ s := newTestServer(t, config)
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 403, response.Code)
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ s := newTestServer(t, config)
+
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+ require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ // should've been deleted with the account
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_Publish(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ require.Equal(t, "/push-receive", r.URL.Path)
+ require.Equal(t, "high", r.Header.Get("Urgency"))
+ require.Equal(t, "", r.Header.Get("Topic"))
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
+ request(t, s, "POST", "/test-topic", "web push test", nil)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+}
+
+func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ w.WriteHeader(http.StatusGone)
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+ requireSubscriptionCount(t, s, "test-topic-abc", 1)
+
+ request(t, s, "POST", "/test-topic", "web push test", nil)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+
+ // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+ requireSubscriptionCount(t, s, "test-topic-abc", 0)
+}
+
+func TestServer_WebPush_Expiry(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ w.WriteHeader(200)
+ w.Write([]byte(``))
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ _, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix())
+ require.Nil(t, err)
+
+ s.pruneAndNotifyWebPushSubscriptions()
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+
+ _, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix())
+ require.Nil(t, err)
+
+ s.pruneAndNotifyWebPushSubscriptions()
+ waitFor(t, func() bool {
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ return len(subs) == 0
+ })
+}
+
+func payloadForTopics(t *testing.T, topics []string, endpoint string) string {
+ topicsJSON, err := json.Marshal(topics)
+ require.Nil(t, err)
+
+ return fmt.Sprintf(`{
+ "topics": %s,
+ "endpoint": "%s",
+ "p256dh": "p256dh-key",
+ "auth": "auth-key"
+ }`, topicsJSON, endpoint)
+}
+
+func addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) {
+ require.Nil(t, s.webPush.UpsertSubscription(endpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", "u_123", netip.MustParseAddr("1.2.3.4"), topics)) // Test auth and p256dh
+}
+
+func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) {
+ subs, err := s.webPush.SubscriptionsForTopic(topic)
+ require.Nil(t, err)
+ require.Len(t, subs, expectedLength)
+}
diff --git a/server/types.go b/server/types.go
index 9e4ff558a..043263a72 100644
--- a/server/types.go
+++ b/server/types.go
@@ -1,12 +1,13 @@
package server
import (
- "heckel.io/ntfy/log"
- "heckel.io/ntfy/user"
"net/http"
"net/netip"
"time"
+ "heckel.io/ntfy/log"
+ "heckel.io/ntfy/user"
+
"heckel.io/ntfy/util"
)
@@ -40,7 +41,7 @@ type message struct {
PollID string `json:"poll_id,omitempty"`
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
- User string `json:"-"` // Username of the uploader, used to associated attachments
+ User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
@@ -397,7 +398,9 @@ type apiConfigResponse struct {
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableReservations bool `json:"enable_reservations"`
+ EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
+ WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
}
@@ -462,3 +465,75 @@ type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"`
Customer string `json:"customer"`
}
+
+type apiWebPushUpdateSubscriptionRequest struct {
+ Endpoint string `json:"endpoint"`
+ Auth string `json:"auth"`
+ P256dh string `json:"p256dh"`
+ Topics []string `json:"topics"`
+}
+
+// List of possible Web Push events (see sw.js)
+const (
+ webPushMessageEvent = "message"
+ webPushExpiringEvent = "subscription_expiring"
+)
+
+type webPushPayload struct {
+ Event string `json:"event"`
+ SubscriptionID string `json:"subscription_id"`
+ Message *message `json:"message"`
+}
+
+func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
+ return &webPushPayload{
+ Event: webPushMessageEvent,
+ SubscriptionID: subscriptionID,
+ Message: message,
+ }
+}
+
+type webPushControlMessagePayload struct {
+ Event string `json:"event"`
+}
+
+func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
+ return &webPushControlMessagePayload{
+ Event: webPushExpiringEvent,
+ }
+}
+
+type webPushSubscription struct {
+ ID string
+ Endpoint string
+ Auth string
+ P256dh string
+ UserID string
+}
+
+func (w *webPushSubscription) Context() log.Context {
+ return map[string]any{
+ "web_push_subscription_id": w.ID,
+ "web_push_subscription_user_id": w.UserID,
+ "web_push_subscription_endpoint": w.Endpoint,
+ }
+}
+
+// https://developer.mozilla.org/en-US/docs/Web/Manifest
+type webManifestResponse struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ShortName string `json:"short_name"`
+ Scope string `json:"scope"`
+ StartURL string `json:"start_url"`
+ Display string `json:"display"`
+ BackgroundColor string `json:"background_color"`
+ ThemeColor string `json:"theme_color"`
+ Icons []*webManifestIcon `json:"icons"`
+}
+
+type webManifestIcon struct {
+ SRC string `json:"src"`
+ Sizes string `json:"sizes"`
+ Type string `json:"type"`
+}
diff --git a/server/webpush_store.go b/server/webpush_store.go
new file mode 100644
index 000000000..b2ab0d11f
--- /dev/null
+++ b/server/webpush_store.go
@@ -0,0 +1,280 @@
+package server
+
+import (
+ "database/sql"
+ "errors"
+ "heckel.io/ntfy/util"
+ "net/netip"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3" // SQLite driver
+)
+
+const (
+ subscriptionIDPrefix = "wps_"
+ subscriptionIDLength = 10
+ subscriptionEndpointLimitPerSubscriberIP = 10
+)
+
+var (
+ errWebPushNoRows = errors.New("no rows found")
+ errWebPushTooManySubscriptions = errors.New("too many subscriptions")
+ errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
+)
+
+const (
+ createWebPushSubscriptionsTableQuery = `
+ BEGIN;
+ CREATE TABLE IF NOT EXISTS subscription (
+ id TEXT PRIMARY KEY,
+ endpoint TEXT NOT NULL,
+ key_auth TEXT NOT NULL,
+ key_p256dh TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ subscriber_ip TEXT NOT NULL,
+ updated_at INT NOT NULL,
+ warned_at INT NOT NULL DEFAULT 0
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
+ CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
+ CREATE TABLE IF NOT EXISTS subscription_topic (
+ subscription_id TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ PRIMARY KEY (subscription_id, topic),
+ FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
+ CREATE TABLE IF NOT EXISTS schemaVersion (
+ id INT PRIMARY KEY,
+ version INT NOT NULL
+ );
+ COMMIT;
+ `
+ builtinStartupQueries = `
+ PRAGMA foreign_keys = ON;
+ `
+
+ selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
+ selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
+ selectWebPushSubscriptionsForTopicQuery = `
+ SELECT id, endpoint, key_auth, key_p256dh, user_id
+ FROM subscription_topic st
+ JOIN subscription s ON s.id = st.subscription_id
+ WHERE st.topic = ?
+ ORDER BY endpoint
+ `
+ selectWebPushSubscriptionsExpiringSoonQuery = `
+ SELECT id, endpoint, key_auth, key_p256dh, user_id
+ FROM subscription
+ WHERE warned_at = 0 AND updated_at <= ?
+ `
+ insertWebPushSubscriptionQuery = `
+ INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (endpoint)
+ DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
+ `
+ updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
+ deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
+ deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
+ deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
+
+ insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
+ deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
+)
+
+// Schema management queries
+const (
+ currentWebPushSchemaVersion = 1
+ insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
+ selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
+)
+
+type webPushStore struct {
+ db *sql.DB
+}
+
+func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
+ db, err := sql.Open("sqlite3", filename)
+ if err != nil {
+ return nil, err
+ }
+ if err := setupWebPushDB(db); err != nil {
+ return nil, err
+ }
+ if err := runWebPushStartupQueries(db, startupQueries); err != nil {
+ return nil, err
+ }
+ return &webPushStore{
+ db: db,
+ }, nil
+}
+
+func setupWebPushDB(db *sql.DB) error {
+ // If 'schemaVersion' table does not exist, this must be a new database
+ rows, err := db.Query(selectWebPushSchemaVersionQuery)
+ if err != nil {
+ return setupNewWebPushDB(db)
+ }
+ return rows.Close()
+}
+
+func setupNewWebPushDB(db *sql.DB) error {
+ if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
+ return err
+ }
+ if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
+ return err
+ }
+ return nil
+}
+
+func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
+ if _, err := db.Exec(startupQueries); err != nil {
+ return err
+ }
+ if _, err := db.Exec(builtinStartupQueries); err != nil {
+ return err
+ }
+ return nil
+}
+
+// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
+// existing entries for a given endpoint.
+func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ // Read number of subscriptions for subscriber IP address
+ rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
+ if err != nil {
+ return err
+ }
+ defer rowsCount.Close()
+ var subscriptionCount int
+ if !rowsCount.Next() {
+ return errWebPushNoRows
+ }
+ if err := rowsCount.Scan(&subscriptionCount); err != nil {
+ return err
+ }
+ if err := rowsCount.Close(); err != nil {
+ return err
+ }
+ // Read existing subscription ID for endpoint (or create new ID)
+ rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ var subscriptionID string
+ if rows.Next() {
+ if err := rows.Scan(&subscriptionID); err != nil {
+ return err
+ }
+ } else {
+ if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
+ return errWebPushTooManySubscriptions
+ }
+ subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ // Insert or update subscription
+ updatedAt, warnedAt := time.Now().Unix(), 0
+ if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
+ return err
+ }
+ // Replace all subscription topics
+ if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
+ return err
+ }
+ for _, topic := range topics {
+ if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// SubscriptionsForTopic returns all subscriptions for the given topic
+func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
+ rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return c.subscriptionsFromRows(rows)
+}
+
+// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
+func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
+ rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return c.subscriptionsFromRows(rows)
+}
+
+// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
+func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, subscription := range subscriptions {
+ if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
+ subscriptions := make([]*webPushSubscription, 0)
+ for rows.Next() {
+ var id, endpoint, auth, p256dh, userID string
+ if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
+ return nil, err
+ }
+ subscriptions = append(subscriptions, &webPushSubscription{
+ ID: id,
+ Endpoint: endpoint,
+ Auth: auth,
+ P256dh: p256dh,
+ UserID: userID,
+ })
+ }
+ return subscriptions, nil
+}
+
+// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
+func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
+ _, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
+ return err
+}
+
+// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
+func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
+ if userID == "" {
+ return errWebPushUserIDCannotBeEmpty
+ }
+ _, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
+ return err
+}
+
+// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
+func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
+ _, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
+ return err
+}
+
+// Close closes the underlying database connection
+func (c *webPushStore) Close() error {
+ return c.db.Close()
+}
diff --git a/server/webpush_store_test.go b/server/webpush_store_test.go
new file mode 100644
index 000000000..ab5bc4242
--- /dev/null
+++ b/server/webpush_store_test.go
@@ -0,0 +1,199 @@
+package server
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/require"
+ "net/netip"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ subs, err := webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
+ require.Equal(t, subs[0].P256dh, "p256dh-key")
+ require.Equal(t, subs[0].Auth, "auth-key")
+ require.Equal(t, subs[0].UserID, "u_1234")
+
+ subs2, err := webPush.SubscriptionsForTopic("mytopic")
+ require.Nil(t, err)
+ require.Len(t, subs2, 1)
+ require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
+}
+
+func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert 10 subscriptions with the same IP address
+ for i := 0; i < 10; i++ {
+ endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
+ require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+ }
+
+ // Another one for the same endpoint should be fine
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ // But with a different endpoint it should fail
+ require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ // But with a different IP address it should be fine again
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
+}
+
+func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics, and another with one topic
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
+
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 2)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+ require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
+
+ subs, err = webPush.SubscriptionsForTopic("topic2")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+
+ // Update the first subscription to have only one topic
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
+
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 2)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+
+ subs, err = webPush.SubscriptionsForTopic("topic2")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // And remove it again
+ require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // And remove it again
+ require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+ require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
+}
+
+func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Mark them as warning sent
+ require.Nil(t, webPush.MarkExpiryWarningSent(subs))
+
+ rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
+ require.Nil(t, err)
+ defer rows.Close()
+ var endpoint string
+ require.True(t, rows.Next())
+ require.Nil(t, rows.Scan(&endpoint))
+ require.Nil(t, err)
+ require.Equal(t, testWebPushEndpoint, endpoint)
+ require.False(t, rows.Next())
+}
+
+func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Fake-mark them as soon-to-expire
+ _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
+ require.Nil(t, err)
+
+ // Should not be cleaned up yet
+ require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
+
+ // Run expiration
+ subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
+}
+
+func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Fake-mark them as expired
+ _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
+ require.Nil(t, err)
+
+ // Run expiration
+ require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
+
+ // List again, should be 0
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func newTestWebPushStore(t *testing.T) *webPushStore {
+ webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
+ require.Nil(t, err)
+ return webPush
+}
diff --git a/web/.eslintrc b/web/.eslintrc
index adf661300..a21221fc4 100644
--- a/web/.eslintrc
+++ b/web/.eslintrc
@@ -33,5 +33,6 @@
"unnamedComponents": "arrow-function"
}
]
- }
+ },
+ "overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
}
diff --git a/web/index.html b/web/index.html
index c146e64db..462bbc1fc 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,11 +13,17 @@
+
+
+
@@ -35,6 +41,9 @@
+
+
+