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: - -
- ![pinned](../static/img/web-pin.png){ width=500 } -
Pin web app to move it 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 @@ + + +