Add PWA, service worker and Web Push

- Use new notification request/opt-in flow for push
- Implement unsubscribing
- Implement muting
- Implement emojis in title
- Add iOS specific PWA warning
- Don’t use websockets when web push is enabled
- Fix duplicate notifications
- Implement default web push setting
- Implement changing subscription type
- Implement web push subscription refresh
- Implement web push notification click
This commit is contained in:
nimbleghost 2023-05-24 21:36:01 +02:00
parent 733ef4664b
commit ff5c854192
53 changed files with 4363 additions and 249 deletions

1
.gitignore vendored
View File

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

View File

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

39
cmd/web_push.go Normal file
View File

@ -0,0 +1,39 @@
//go:build !noserver
package cmd
import (
"fmt"
"github.com/SherClockHolmes/webpush-go"
"github.com/urfave/cli/v2"
)
func init() {
commands = append(commands, cmdWebPush)
}
var cmdWebPush = &cli.Command{
Name: "web-push-keys",
Usage: "Generate web push VAPID keys",
UsageText: "ntfy web-push-keys",
Category: categoryServer,
Action: generateWebPushKeys,
}
func generateWebPushKeys(c *cli.Context) error {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, `Add the following lines to your config file:
web-push-enabled: true
web-push-public-key: %s
web-push-private-key: %s
web-push-subscriptions-file: <filename>
web-push-email-address: <email address>
`, publicKey, privateKey)
return nil
}

View File

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

View File

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

2
go.mod
View File

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

5
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

132
server/web_push.go Normal file
View File

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

View File

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

View File

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

2652
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

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

111
web/public/sw.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34
web/src/app/getDb.js Normal file
View File

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

View File

@ -20,7 +20,10 @@ export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/jso
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push`;
export const topicUrlWebPushUnsubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/unsubscribe`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const webPushConfigUrl = (baseUrl) => `${baseUrl}/v1/web-push-config`;
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
@ -156,7 +159,7 @@ export const splitNoEmpty = (s, delimiter) =>
.filter((x) => x !== "");
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
export const hashCode = (s) => {
let hash = 0;
for (let i = 0; i < s.length; i += 1) {
const char = s.charCodeAt(i);
@ -288,3 +291,16 @@ export const randomAlphanumericString = (len) => {
}
return id;
};
export const urlB64ToUint8Array = (base64String) => {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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