From ff5c854192dafd99d57204ce690d6fbe3828ceb6 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Wed, 24 May 2023 21:36:01 +0200 Subject: [PATCH] Add PWA, service worker and Web Push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + cmd/serve.go | 17 + cmd/web_push.go | 39 + docs/config.md | 31 +- docs/develop.md | 63 +- go.mod | 2 + go.sum | 5 + server/config.go | 11 +- server/errors.go | 1 + server/server.go | 290 +- server/server.yml | 10 + server/server_account.go | 7 + server/server_middleware.go | 9 + server/server_test.go | 13 + server/smtp_sender.go | 24 - server/types.go | 24 + server/util.go | 24 + server/web_push.go | 132 + web/.eslintrc | 3 +- web/index.html | 7 + web/package-lock.json | 2652 ++++++++++++++++- web/package.json | 3 +- web/public/config.js | 2 +- web/public/static/images/apple-touch-icon.png | Bin 0 -> 15584 bytes web/public/static/images/mask-icon.svg | 20 + web/public/static/images/pwa-192x192.png | Bin 0 -> 6614 bytes web/public/static/images/pwa-512x512.png | Bin 0 -> 19716 bytes web/public/static/langs/en.json | 18 +- web/public/sw.js | 111 + web/src/app/AccountApi.js | 4 + web/src/app/Api.js | 59 + web/src/app/ConnectionManager.js | 17 +- web/src/app/Notifier.js | 104 +- web/src/app/Poller.js | 9 +- web/src/app/Prefs.js | 30 +- web/src/app/Pruner.js | 4 + web/src/app/Session.js | 12 +- web/src/app/SessionReplica.js | 44 + web/src/app/SubscriptionManager.js | 198 +- web/src/app/UserManager.js | 17 +- web/src/app/WebPushWorker.js | 46 + web/src/app/db.js | 21 - web/src/app/getDb.js | 34 + web/src/app/utils.js | 18 +- web/src/components/Account.jsx | 7 +- web/src/components/ActionBar.jsx | 6 +- web/src/components/App.jsx | 4 + web/src/components/Navigation.jsx | 44 +- web/src/components/Preferences.jsx | 32 + web/src/components/SubscribeDialog.jsx | 121 +- web/src/components/SubscriptionPopup.jsx | 168 +- web/src/components/hooks.js | 33 +- web/vite.config.js | 61 +- 53 files changed, 4363 insertions(+), 249 deletions(-) create mode 100644 cmd/web_push.go create mode 100644 server/web_push.go create mode 100644 web/public/static/images/apple-touch-icon.png create mode 100644 web/public/static/images/mask-icon.svg create mode 100644 web/public/static/images/pwa-192x192.png create mode 100644 web/public/static/images/pwa-512x512.png create mode 100644 web/public/sw.js create mode 100644 web/src/app/SessionReplica.js create mode 100644 web/src/app/WebPushWorker.js delete mode 100644 web/src/app/db.js create mode 100644 web/src/app/getDb.js diff --git a/.gitignore b/.gitignore index f695607e..b60c9b23 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ secrets/ node_modules/ .DS_Store __pycache__ +web/dev-dist/ \ No newline at end of file diff --git a/cmd/serve.go b/cmd/serve.go index 5d5381bf..a5105bcd 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -94,6 +94,11 @@ var flagsServe = append( altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}), + altsrc.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) diff --git a/cmd/web_push.go b/cmd/web_push.go new file mode 100644 index 00000000..becaffd7 --- /dev/null +++ b/cmd/web_push.go @@ -0,0 +1,39 @@ +//go:build !noserver + +package cmd + +import ( + "fmt" + + "github.com/SherClockHolmes/webpush-go" + "github.com/urfave/cli/v2" +) + +func init() { + commands = append(commands, cmdWebPush) +} + +var cmdWebPush = &cli.Command{ + Name: "web-push-keys", + Usage: "Generate web push VAPID keys", + UsageText: "ntfy web-push-keys", + Category: categoryServer, + Action: generateWebPushKeys, +} + +func generateWebPushKeys(c *cli.Context) error { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + + fmt.Fprintf(c.App.ErrWriter, `Add the following lines to your config file: +web-push-enabled: true +web-push-public-key: %s +web-push-private-key: %s +web-push-subscriptions-file: +web-push-email-address: +`, publicKey, privateKey) + + return nil +} diff --git a/docs/config.md b/docs/config.md index df1f2cd6..774f9b2f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1285,13 +1285,17 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | | `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | +| `web-push-enabled` | `NTFY_WEB_PUSH_ENABLED` | *boolean* (`true` or `false`) | - | Web Push: Enable/disable (requires private and public key below). | +| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy web-push-keys` to generate | +| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push-keys` to generate | +| `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file | +| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. ## Command line options ``` -$ ntfy serve --help NAME: ntfy serve - Run the ntfy server @@ -1321,8 +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 ``` - diff --git a/docs/develop.md b/docs/develop.md index baab3f3a..6be65abd 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -16,7 +16,7 @@ server consists of three components: * **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to build the docs. -* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/) +* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/) to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`) and install all the 100,000 dependencies (*sigh*). @@ -241,6 +241,67 @@ $ cd web $ npm start ``` +### Testing Web Push locally + +Reference: + +#### 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 +#### With a built package + +1. Run `make web-build` + +2. Follow steps 1, 2, 4 and 5 from "With the dev servers" + +3. Open ### Build the docs The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the documentation. As long as you have `mkdocs` installed (see above), this should work fine: diff --git a/go.mod b/go.mod index 19af7ba5..dda58c9b 100644 --- a/go.mod +++ b/go.mod @@ -39,10 +39,12 @@ require ( cloud.google.com/go/longrunning v0.5.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/SherClockHolmes/webpush-go v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect diff --git a/go.sum b/go.sum index 999fc8ac..d8e78b86 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/BurntSushi/toml v1.3.1 h1:rHnDkSK+/g6DlREUK73PkmIs60pqrnuduK+JmP++JmU github.com/BurntSushi/toml v1.3.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA= +github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -57,6 +59,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -149,6 +153,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/server/config.go b/server/config.go index a876926e..f7c1813d 100644 --- a/server/config.go +++ b/server/config.go @@ -1,10 +1,11 @@ package server import ( - "heckel.io/ntfy/user" "io/fs" "net/netip" "time" + + "heckel.io/ntfy/user" ) // Defines default config settings (excluding limits, see below) @@ -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: "", } } diff --git a/server/errors.go b/server/errors.go index eee916b5..d13e2969 100644 --- a/server/errors.go +++ b/server/errors.go @@ -114,6 +114,7 @@ var ( errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} + errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index d2fac01f..cba74280 100644 --- a/server/server.go +++ b/server/server.go @@ -9,13 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/emersion/go-smtp" - "github.com/gorilla/websocket" - "github.com/prometheus/client_golang/prometheus/promhttp" - "golang.org/x/sync/errgroup" - "heckel.io/ntfy/log" - "heckel.io/ntfy/user" - "heckel.io/ntfy/util" "io" "net" "net/http" @@ -32,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: or 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: or 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 } diff --git a/server/server.yml b/server/server.yml index 9c7972e9..ecb89994 100644 --- a/server/server.yml +++ b/server/server.yml @@ -38,6 +38,16 @@ # # firebase-key-file: +# 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. # diff --git a/server/server_account.go b/server/server_account.go index 6e6a6864..dbadb81a 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -170,6 +170,13 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { return errHTTPBadRequestIncorrectPasswordConfirmation } + if s.webPushSubscriptionStore != nil { + err := s.webPushSubscriptionStore.ExpireWebPushForUser(u.Name) + + if err != nil { + logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name) + } + } if u.Billing.StripeSubscriptionID != "" { logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name) if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil { diff --git a/server/server_middleware.go b/server/server_middleware.go index 7aea45a3..41c2706c 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -58,6 +58,15 @@ func (s *Server) ensureWebEnabled(next handleFunc) handleFunc { } } +func (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if !s.config.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 { diff --git a/server/server_test.go b/server/server_test.go index d7c4a7c6..b9f2912d 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -238,6 +238,12 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s, "GET", "/config.js", "", nil) require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 404, rr.Code) + + rr = request(t, s, "GET", "/sw.js", "", nil) + require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/static/css/home.css", "", nil) require.Equal(t, 404, rr.Code) @@ -250,6 +256,13 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s2, "GET", "/config.js", "", nil) require.Equal(t, 200, rr.Code) + + rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil) + require.Equal(t, 200, rr.Code) + require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type")) + + rr = request(t, s2, "GET", "/sw.js", "", nil) + require.Equal(t, 200, rr.Code) } func TestServer_PublishLargeMessage(t *testing.T) { diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 9093687e..759ef396 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -1,8 +1,6 @@ package server import ( - _ "embed" // required by go:embed - "encoding/json" "fmt" "mime" "net" @@ -130,25 +128,3 @@ This message was sent by {ip} at {time} via {topicURL}` body = strings.ReplaceAll(body, "{ip}", senderIP) return body, nil } - -var ( - //go:embed "mailer_emoji_map.json" - emojisJSON string -) - -func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojiMap map[string]string - if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { - return nil, nil, err - } - tagsOut = make([]string, 0) - emojisOut = make([]string, 0) - for _, t := range tags { - if emoji, ok := emojiMap[t]; ok { - emojisOut = append(emojisOut, emoji) - } else { - tagsOut = append(tagsOut, t) - } - } - return -} diff --git a/server/types.go b/server/types.go index 9e4ff558..6eed5eef 100644 --- a/server/types.go +++ b/server/types.go @@ -7,6 +7,7 @@ import ( "net/netip" "time" + "github.com/SherClockHolmes/webpush-go" "heckel.io/ntfy/util" ) @@ -401,6 +402,10 @@ type apiConfigResponse struct { DisallowedTopics []string `json:"disallowed_topics"` } +type apiWebPushConfigResponse struct { + PublicKey string `json:"public_key"` +} + type apiAccountBillingPrices struct { Month int64 `json:"month"` Year int64 `json:"year"` @@ -462,3 +467,22 @@ type apiStripeSubscriptionDeletedEvent struct { ID string `json:"id"` Customer string `json:"customer"` } + +type webPushPayload struct { + SubscriptionID string `json:"subscription_id"` + Message message `json:"message"` + FormattedTitle string `json:"formatted_title"` +} + +type webPushSubscription struct { + BrowserSubscription webpush.Subscription + Username string +} + +type webPushSubscribePayload struct { + BrowserSubscription webpush.Subscription `json:"browser_subscription"` +} + +type webPushUnsubscribePayload struct { + Endpoint string `json:"endpoint"` +} diff --git a/server/util.go b/server/util.go index 03eb8661..be724c76 100644 --- a/server/util.go +++ b/server/util.go @@ -2,6 +2,8 @@ package server import ( "context" + _ "embed" // required by go:embed + "encoding/json" "fmt" "heckel.io/ntfy/util" "io" @@ -133,3 +135,25 @@ func maybeDecodeHeader(header string) string { } return decoded } + +var ( + //go:embed "mailer_emoji_map.json" + emojisJSON string +) + +func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { + var emojiMap map[string]string + if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { + return nil, nil, err + } + tagsOut = make([]string, 0) + emojisOut = make([]string, 0) + for _, t := range tags { + if emoji, ok := emojiMap[t]; ok { + emojisOut = append(emojisOut, emoji) + } else { + tagsOut = append(tagsOut, t) + } + } + return +} diff --git a/server/web_push.go b/server/web_push.go new file mode 100644 index 00000000..fe9f5149 --- /dev/null +++ b/server/web_push.go @@ -0,0 +1,132 @@ +package server + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" // SQLite driver +) + +// Messages cache +const ( + createWebPushSubscriptionsTableQuery = ` + BEGIN; + CREATE TABLE IF NOT EXISTS web_push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic TEXT NOT NULL, + username TEXT, + endpoint TEXT NOT NULL, + key_auth TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_topic ON web_push_subscriptions (topic); + CREATE INDEX IF NOT EXISTS idx_endpoint ON web_push_subscriptions (endpoint); + CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON web_push_subscriptions (topic, endpoint); + COMMIT; + ` + insertWebPushSubscriptionQuery = ` + INSERT OR REPLACE INTO web_push_subscriptions (topic, username, endpoint, key_auth, key_p256dh) + VALUES (?, ?, ?, ?, ?); + ` + deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM web_push_subscriptions WHERE endpoint = ?` + deleteWebPushSubscriptionByUsernameQuery = `DELETE FROM web_push_subscriptions WHERE username = ?` + deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM web_push_subscriptions WHERE topic = ? AND endpoint = ?` + + selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, username FROM web_push_subscriptions WHERE topic = ?` + + selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM web_push_subscriptions` +) + +type webPushSubscriptionStore struct { + db *sql.DB +} + +func newWebPushSubscriptionStore(filename string) (*webPushSubscriptionStore, error) { + db, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + if err := setupSubscriptionDb(db); err != nil { + return nil, err + } + webPushSubscriptionStore := &webPushSubscriptionStore{ + db: db, + } + return webPushSubscriptionStore, nil +} + +func setupSubscriptionDb(db *sql.DB) error { + // If 'messages' table does not exist, this must be a new database + rowsMC, err := db.Query(selectWebPushSubscriptionsCountQuery) + if err != nil { + return setupNewSubscriptionDb(db) + } + rowsMC.Close() + return nil +} + +func setupNewSubscriptionDb(db *sql.DB) error { + if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil { + return err + } + return nil +} + +func (c *webPushSubscriptionStore) AddSubscription(topic string, username string, subscription webPushSubscribePayload) error { + _, err := c.db.Exec( + insertWebPushSubscriptionQuery, + topic, + username, + subscription.BrowserSubscription.Endpoint, + subscription.BrowserSubscription.Keys.Auth, + subscription.BrowserSubscription.Keys.P256dh, + ) + return err +} + +func (c *webPushSubscriptionStore) RemoveSubscription(topic string, endpoint string) error { + _, err := c.db.Exec( + deleteWebPushSubscriptionByTopicAndEndpointQuery, + topic, + endpoint, + ) + return err +} + +func (c *webPushSubscriptionStore) GetSubscriptionsForTopic(topic string) (subscriptions []webPushSubscription, err error) { + rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic) + if err != nil { + return nil, err + } + defer rows.Close() + + data := []webPushSubscription{} + for rows.Next() { + i := webPushSubscription{} + err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh, &i.Username) + if err != nil { + return nil, err + } + data = append(data, i) + } + return data, nil +} + +func (c *webPushSubscriptionStore) ExpireWebPushEndpoint(endpoint string) error { + _, err := c.db.Exec( + deleteWebPushSubscriptionByEndpointQuery, + endpoint, + ) + return err +} + +func (c *webPushSubscriptionStore) ExpireWebPushForUser(username string) error { + _, err := c.db.Exec( + deleteWebPushSubscriptionByUsernameQuery, + username, + ) + return err +} +func (c *webPushSubscriptionStore) Close() error { + return c.db.Close() +} diff --git a/web/.eslintrc b/web/.eslintrc index adf66130..a21221fc 100644 --- a/web/.eslintrc +++ b/web/.eslintrc @@ -33,5 +33,6 @@ "unnamedComponents": "arrow-function" } ] - } + }, + "overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }] } diff --git a/web/index.html b/web/index.html index c146e64d..82bae45e 100644 --- a/web/index.html +++ b/web/index.html @@ -13,11 +13,18 @@ + + + + diff --git a/web/package-lock.json b/web/package-lock.json index a8637edb..c0ef7ef4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,7 +37,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" } }, "node_modules/@ampproject/remapping": { @@ -118,6 +119,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.3.tgz", + "integrity": "sha512-ahEoxgqNoYXm0k22TvOke48i1PkavGu0qGCmcq9ugi6gnmvKNaMjKBSrZTnWUi1CFEeNAUiVba0Wtzm03aSkJg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.22.1", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.1.tgz", @@ -137,6 +162,63 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.1.tgz", + "integrity": "sha512-SowrZ9BWzYFgzUMwUmowbPSGu6CXL5MSuuCkG3bejahSpSymioPmuLdhPxNOc9MjuNGjy7M/HaXvJ8G82Lywlw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.22.1", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-member-expression-to-functions": "^7.22.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.22.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/helper-split-export-declaration": "^7.18.6", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.1.tgz", + "integrity": "sha512-WWjdnfR3LPIe+0EY8td7WmjhytxXtjKAEpnAxun/hkNiyOaPlvGK+NZaBFIdi9ndYV3Gav7BpFvtUwnaJlwi1w==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.3.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.0.tgz", + "integrity": "sha512-RnanLx5ETe6aybRi1cO/edaRH+bNYWaryCEmjDDYyNr4wnSzyOp8T0dWipmqVHKEY3AbVKUom50AKSlj1zmKbg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0-0" + } + }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.1", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.1.tgz", @@ -171,6 +253,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.3.tgz", + "integrity": "sha512-Gl7sK04b/2WOb6OPVeNy9eFKeD3L6++CzL3ykPOWqTn08xgYYK0wz4TUh2feIImDXxcVW3/9WQ1NMKY66/jfZA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", @@ -201,6 +295,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", @@ -210,6 +316,41 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.1.tgz", + "integrity": "sha512-ut4qrkE4AuSfrwHSps51ekR1ZY/ygrP1tp0WFm8oVq6nzc/hvfV/22JylndIbsf2U2M9LOMwiSddr6y+78j+OQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.1", + "@babel/helper-member-expression-to-functions": "^7.22.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/template": "^7.21.9", + "@babel/traverse": "^7.22.1", + "@babel/types": "^7.22.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-simple-access": { "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", @@ -222,6 +363,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz", + "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", @@ -259,6 +412,21 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz", + "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==", + "dev": true, + "dependencies": { + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.20.5", + "@babel/types": "^7.20.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helpers": { "version": "7.22.3", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.3.tgz", @@ -298,6 +466,909 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.3.tgz", + "integrity": "sha512-6r4yRwEnorYByILoDRnEqxtojYKuiIv9FojW2E8GUKo9eWBwbKcd9IiZOZpdyXc64RmyGGyPu3/uAcrz/dq2kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-transform-optional-chaining": "^7.22.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz", + "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.3.tgz", + "integrity": "sha512-i35jZJv6aO7hxEbIWQ41adVfOzjm9dcYDNeWlBMd8p0ZQRtNUCBrmGwZt+H5lb+oOC9a3svp956KP0oWGA1YsA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz", + "integrity": "sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.3.tgz", + "integrity": "sha512-36A4Aq48t66btydbZd5Fk0/xJqbpg/v4QWI4AH4cYHBXy9Mu42UOupZpebKFiCFNT9S9rJFcsld0gsv0ayLjtA==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz", + "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.21.0.tgz", + "integrity": "sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.3.tgz", + "integrity": "sha512-mASLsd6rhOrLZ5F3WbCxkzl67mmOnqik0zrg5W6D/X0QMW7HtvnoL1dRARLKIbMP3vXwkwziuLesPqWVGIl6Bw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.3.tgz", + "integrity": "sha512-5BirgNWNOx7cwbTJCOmKFJ1pZjwk5MUfMIwiBBvsirCJMZeQgs5pk6i1OlkVg+1Vef5LfBahFOrdCnAWvkVKMw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz", + "integrity": "sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.21.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-split-export-declaration": "^7.18.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.21.5.tgz", + "integrity": "sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/template": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.21.3.tgz", + "integrity": "sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.1.tgz", + "integrity": "sha512-rlhWtONnVBPdmt+jeewS0qSnMz/3yLFrqAP8hHC6EDcrYRSyuz9f9yQhHvVn2Ad6+yO9fHXac5piudeYrInxwQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.3.tgz", + "integrity": "sha512-5Ti1cHLTDnt3vX61P9KZ5IG09bFXp4cDVFJIAeCZuxu9OXXJJZp5iP0n/rzM2+iAutJY+KWEyyHcRaHlpQ/P5g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz", + "integrity": "sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.3.tgz", + "integrity": "sha512-IuvOMdeOOY2X4hRNAT6kwbePtK21BUyrAEgLKviL8pL6AEEVUVcqtRdN/HJXBLGIbt9T3ETmXRnFedRRmQNTYw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.3.tgz", + "integrity": "sha512-CbayIfOw4av2v/HYZEsH+Klks3NC2/MFIR3QR8gnpGNNPEaq2fdlVCRYG/paKs7/5hvBLQ+H70pGWOHtlNEWNA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz", + "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.5.tgz", + "integrity": "sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.21.5", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-simple-access": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.3.tgz", + "integrity": "sha512-V21W3bKLxO3ZjcBJZ8biSvo5gQ85uIXW2vJfh7JSWf/4SLUSr1tOoHX3ruN4+Oqa2m+BKfsxTR1I+PsvkIWvNw==", + "dev": true, + "dependencies": { + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-validator-identifier": "^7.19.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.3.tgz", + "integrity": "sha512-c6HrD/LpUdNNJsISQZpds3TXvfYIAbo+efE9aWmY/PmSRD0agrJ9cPMt4BmArwUQ7ZymEWTFjTyp+yReLJZh0Q==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.3.tgz", + "integrity": "sha512-5RuJdSo89wKdkRTqtM9RVVJzHum9c2s0te9rB7vZC1zKKxcioWIy+xcu4OoIAjyFZhb/bp5KkunuLin1q7Ct+w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.3.tgz", + "integrity": "sha512-CpaoNp16nX7ROtLONNuCyenYdY/l7ZsR6aoVa7rW7nMWisoNoQNIH5Iay/4LDyRjKMuElMqXiBoOQCDLTMGZiw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.3.tgz", + "integrity": "sha512-+AF88fPDJrnseMh5vD9+SH6wq4ZMvpiTMHh58uLs+giMEyASFVhcT3NkoyO+NebFCNnpHJEq5AXO2txV4AGPDQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.3.tgz", + "integrity": "sha512-38bzTsqMMCI46/TQnJwPPpy33EjLCc1Gsm2hRTF6zTMWnKsN61vdrpuzIEGQyKEhDSYDKyZHrrd5FMj4gcUHhw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.3", + "@babel/helper-compilation-targets": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.22.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.3.tgz", + "integrity": "sha512-bnDFWXFzWY0BsOyqaoSXvMQ2F35zutQipugog/rqotL2S4ciFOKlRYUu9djt4iq09oh2/34hqfRR2k1dIvuu4g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.3.tgz", + "integrity": "sha512-63v3/UFFxhPKT8j8u1jTTGVyITxl7/7AfOqK8C5gz1rHURPUGe3y5mvIf68eYKGoBNahtJnTxBKug4BQOnzeJg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.3.tgz", + "integrity": "sha512-x7QHQJHPuD9VmfpzboyGJ5aHEr9r7DsAsdxdhJiTB3J3j8dyl+NFZ+rX5Q2RWFDCs61c06qBfS4ys2QYn8UkMw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.3.tgz", + "integrity": "sha512-fC7jtjBPFqhqpPAE+O4LKwnLq7gGkD3ZmC2E3i4qWH34mH3gOg2Xrq5YMHUq6DM30xhqM1DNftiRaSqVjEG+ug==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.3.tgz", + "integrity": "sha512-C7MMl4qWLpgVCbXfj3UW8rR1xeCnisQ0cU7YJHV//8oNBS0aCIVg1vFnZXxOckHhEpQyqNNkWmvSEWnMLlc+Vw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.21.0.tgz", @@ -328,6 +1399,292 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz", + "integrity": "sha512-ZoYBKDb6LyMi5yCsByQ5jmXsHAQDDYeexT1Szvlmui+lADvfSecr5Dxd/PkrTC3pAD182Fcju1VQkB4oCp9M+w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5", + "regenerator-transform": "^0.15.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz", + "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.21.5.tgz", + "integrity": "sha512-LYm/gTOwZqsYohlvFUe/8Tujz75LqqVC2w+2qPHLR+WyWHGCZPN1KBpJCJn+4Bk4gOkQy/IXKIge6az5MqwlOg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.3.tgz", + "integrity": "sha512-5ScJ+OmdX+O6HRuMGW4kv7RL9vIKdtdAj9wuWUKy1wbHY3jaM/UlyIiC1G7J6UJiiyMukjjK0QwL3P0vBd0yYg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.3.tgz", + "integrity": "sha512-hNufLdkF8vqywRp+P55j4FHXqAX2LRUccoZHH7AFn1pq5ZOO2ISKW9w13bFZVjBoTqeve2HOgoJCcaziJVhGNw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.22.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.4.tgz", + "integrity": "sha512-c3lHOjbwBv0TkhYCr+XCR6wKcSZ1QbQTVdSkZUaVpLv8CVWotBMArWUi5UAJrcrQaEnleVkkvaV8F/pmc/STZQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.3", + "@babel/helper-compilation-targets": "^7.22.1", + "@babel/helper-plugin-utils": "^7.21.5", + "@babel/helper-validator-option": "^7.21.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.3", + "@babel/plugin-proposal-private-property-in-object": "^7.21.0", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.20.0", + "@babel/plugin-syntax-import-attributes": "^7.22.3", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.21.5", + "@babel/plugin-transform-async-generator-functions": "^7.22.3", + "@babel/plugin-transform-async-to-generator": "^7.20.7", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.21.0", + "@babel/plugin-transform-class-properties": "^7.22.3", + "@babel/plugin-transform-class-static-block": "^7.22.3", + "@babel/plugin-transform-classes": "^7.21.0", + "@babel/plugin-transform-computed-properties": "^7.21.5", + "@babel/plugin-transform-destructuring": "^7.21.3", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-dynamic-import": "^7.22.1", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-export-namespace-from": "^7.22.3", + "@babel/plugin-transform-for-of": "^7.21.5", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-json-strings": "^7.22.3", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.22.3", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.20.11", + "@babel/plugin-transform-modules-commonjs": "^7.21.5", + "@babel/plugin-transform-modules-systemjs": "^7.22.3", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.3", + "@babel/plugin-transform-new-target": "^7.22.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.3", + "@babel/plugin-transform-numeric-separator": "^7.22.3", + "@babel/plugin-transform-object-rest-spread": "^7.22.3", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-optional-catch-binding": "^7.22.3", + "@babel/plugin-transform-optional-chaining": "^7.22.3", + "@babel/plugin-transform-parameters": "^7.22.3", + "@babel/plugin-transform-private-methods": "^7.22.3", + "@babel/plugin-transform-private-property-in-object": "^7.22.3", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.21.5", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.20.7", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.21.5", + "@babel/plugin-transform-unicode-property-regex": "^7.22.3", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/plugin-transform-unicode-sets-regex": "^7.22.3", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.22.4", + "babel-plugin-polyfill-corejs2": "^0.4.3", + "babel-plugin-polyfill-corejs3": "^0.8.1", + "babel-plugin-polyfill-regenerator": "^0.5.0", + "core-js-compat": "^3.30.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, "node_modules/@babel/runtime": { "version": "7.22.3", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", @@ -1008,6 +2365,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", + "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -1323,12 +2690,36 @@ "node": ">=14" } }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/node": { + "version": "20.2.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.5.tgz", + "integrity": "sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -1365,11 +2756,26 @@ "@types/react": "*" } }, + "node_modules/@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, + "node_modules/@types/trusted-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==", + "dev": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.0.0.tgz", @@ -1547,6 +2953,21 @@ "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", "dev": true }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1591,6 +3012,45 @@ "npm": ">=6" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.3.tgz", + "integrity": "sha512-bM3gHc337Dta490gg+/AseNB9L4YLHxq1nGKZZSHbhXv4aTYU2MD2cjza1Ru4S6975YLTaL1K8uJf6ukJhhmtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.4.0", + "semver": "^6.1.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.1.tgz", + "integrity": "sha512-ikFrZITKg1xH6pLND8zT14UPgjKHiGLqex7rGEZCH2EvhsneJaJPemmpQaIZV5AL03II+lXylw3UmddDK8RU5Q==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.0", + "core-js-compat": "^3.30.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.0.tgz", + "integrity": "sha512-hDJtKjMLVa7Z+LwnTCxoDLQj6wdc+B8dun7ayF2fYieI6OzfuvcLMB32ihJZ4UhCBwNYGl5bg/x/P9cMdnkc2g==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1607,6 +3067,18 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.21.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.7.tgz", @@ -1639,6 +3111,24 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -1722,6 +3212,21 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1739,6 +3244,19 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/core-js-compat": { + "version": "3.30.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz", + "integrity": "sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -1776,6 +3294,15 @@ "node": ">= 8" } }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -1839,6 +3366,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-properties": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", @@ -1894,6 +3430,21 @@ "csstype": "^3.0.2" } }, + "node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.423", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.423.tgz", @@ -2554,6 +4105,12 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2569,6 +4126,34 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2602,6 +4187,48 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -2651,6 +4278,21 @@ "is-callable": "^1.1.3" } }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2727,6 +4369,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true + }, "node_modules/get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", @@ -2811,6 +4459,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2960,6 +4614,12 @@ "cross-fetch": "3.1.5" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3154,6 +4814,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -3166,6 +4832,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", @@ -3181,6 +4856,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3206,6 +4890,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-set": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", @@ -3227,6 +4920,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -3322,6 +5027,129 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jake/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jake/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/jake/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-base64": { "version": "3.7.5", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", @@ -3361,6 +5189,12 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3385,6 +5219,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -3413,6 +5268,15 @@ "language-subtag-registry": "~0.3.2" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3446,12 +5310,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3472,6 +5354,43 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3799,6 +5718,18 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.4.24", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", @@ -3851,6 +5782,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.0.tgz", + "integrity": "sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==", + "dev": true, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3895,6 +5838,15 @@ } ] }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -4009,11 +5961,38 @@ "react-dom": ">=16.6.0" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/regenerator-transform": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", + "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", @@ -4031,6 +6010,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -4119,6 +6145,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-regex-test": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", @@ -4150,6 +6196,15 @@ "semver": "bin/semver.js" } }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4202,6 +6257,32 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true + }, "node_modules/stack-generator": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", @@ -4318,6 +6399,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4339,6 +6434,15 @@ "node": ">=4" } }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4378,6 +6482,63 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.17.7", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.7.tgz", + "integrity": "sha512-/bi0Zm2C6VAexlGgLlVxA0P2lru/sdLyfCVaRMfKVo9nWxbmz7f/sD8VPybPeSUJaJcwmCJis9pBIhcVcG1QcQ==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4400,6 +6561,18 @@ "node": ">=4" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -4482,6 +6655,77 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "engines": { + "node": ">=4", + "yarn": "*" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", @@ -4569,6 +6813,27 @@ } } }, + "node_modules/vite-plugin-pwa": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.15.2.tgz", + "integrity": "sha512-l1srtaad5NMNrAtAuub6ArTYG5Ci9AwofXXQ6IsbpCMYQ/0HUndwI7RB2x95+1UBFm7VGttQtT7woBlVnNhBRw==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "fast-glob": "^3.2.12", + "pretty-bytes": "^6.0.0", + "workbox-build": "^6.5.4", + "workbox-window": "^6.5.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^3.1.0 || ^4.0.0", + "workbox-build": "^6.5.4", + "workbox-window": "^6.5.4" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -4666,6 +6931,391 @@ "node": ">=0.10.0" } }, + "node_modules/workbox-background-sync": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", + "integrity": "sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==", + "dev": true, + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz", + "integrity": "sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-build": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.6.0.tgz", + "integrity": "sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==", + "dev": true, + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.6.0", + "workbox-broadcast-update": "6.6.0", + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-google-analytics": "6.6.0", + "workbox-navigation-preload": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-range-requests": "6.6.0", + "workbox-recipes": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0", + "workbox-streams": "6.6.0", + "workbox-sw": "6.6.0", + "workbox-window": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-build/node_modules/rollup": { + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/workbox-build/node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dev": true, + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.6.0.tgz", + "integrity": "sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==", + "deprecated": "workbox-background-sync@6.6.0", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.6.0.tgz", + "integrity": "sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==", + "dev": true + }, + "node_modules/workbox-expiration": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.6.0.tgz", + "integrity": "sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==", + "dev": true, + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz", + "integrity": "sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==", + "dev": true, + "dependencies": { + "workbox-background-sync": "6.6.0", + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.6.0.tgz", + "integrity": "sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-precaching": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.6.0.tgz", + "integrity": "sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.6.0.tgz", + "integrity": "sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-recipes": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.6.0.tgz", + "integrity": "sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==", + "dev": true, + "dependencies": { + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-routing": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.6.0.tgz", + "integrity": "sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-strategies": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.6.0.tgz", + "integrity": "sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-streams": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.6.0.tgz", + "integrity": "sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==", + "dev": true, + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0" + } + }, + "node_modules/workbox-sw": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.6.0.tgz", + "integrity": "sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==", + "dev": true + }, + "node_modules/workbox-window": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.6.0.tgz", + "integrity": "sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==", + "dev": true, + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.6.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/web/package.json b/web/package.json index 274e4c8e..2e52635a 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,8 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", - "vite": "^4.3.9" + "vite": "^4.3.9", + "vite-plugin-pwa": "^0.15.0" }, "browserslist": { "production": [ diff --git a/web/public/config.js b/web/public/config.js index 2f46d65c..7bcad73f 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -7,7 +7,7 @@ var config = { base_url: window.location.origin, // Change to test against a different server - app_root: "/app", + app_root: "/", enable_login: true, enable_signup: true, enable_payments: false, diff --git a/web/public/static/images/apple-touch-icon.png b/web/public/static/images/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8f89050995c49f6073a9167c8afd7d2831cac384 GIT binary patch literal 15584 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 MMdd`Qh4cgd7jCG$_W%F@ literal 0 HcmV?d00001 diff --git a/web/public/static/images/mask-icon.svg b/web/public/static/images/mask-icon.svg new file mode 100644 index 00000000..32fced6d --- /dev/null +++ b/web/public/static/images/mask-icon.svg @@ -0,0 +1,20 @@ + + + + + + + diff --git a/web/public/static/images/pwa-192x192.png b/web/public/static/images/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaebcc4da731b451ddfd2403c222ee45b571a46 GIT binary patch literal 6614 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 Yi|2gk?hWM}iV46-&s?|RhD+T402#pN%m4rY literal 0 HcmV?d00001 diff --git a/web/public/static/images/pwa-512x512.png b/web/public/static/images/pwa-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..d9003a1959d28c7aea6d6c2fd726866c5ffe9d0f GIT binary patch literal 19716 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 literal 0 HcmV?d00001 diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 5d8a3a3d..53b8c3f5 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -52,9 +52,10 @@ "nav_button_connecting": "connecting", "nav_upgrade_banner_label": "Upgrade to ntfy Pro", "nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments", - "alert_grant_title": "Notifications are disabled", - "alert_grant_description": "Grant your browser permission to display desktop notifications.", - "alert_grant_button": "Grant now", + "alert_notification_permission_denied_title": "Notifications are blocked", + "alert_notification_permission_denied_description": "Please re-enable them in your browser and refresh the page to receive notifications", + "alert_notification_ios_install_required_title": "iOS Install Required", + "alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS", "alert_not_supported_title": "Notifications not supported", "alert_not_supported_description": "Notifications are not supported in your browser.", "alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the Notifications API.", @@ -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 website or documentation.", + "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.", diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 00000000..43a2e3b3 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,111 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching"; +import { NavigationRoute, registerRoute } from "workbox-routing"; +import { NetworkFirst } from "workbox-strategies"; + +import { getDbAsync } from "../src/app/getDb"; + +// See WebPushWorker, this is to play a sound on supported browsers, +// if the app is in the foreground +const broadcastChannel = new BroadcastChannel("web-push-broadcast"); + +self.addEventListener("install", () => { + console.log("[ServiceWorker] Installed"); + self.skipWaiting(); +}); + +self.addEventListener("activate", () => { + console.log("[ServiceWorker] Activated"); + self.skipWaiting(); +}); + +// There's no good way to test this, and Chrome doesn't seem to implement this, +// so leaving it for now +self.addEventListener("pushsubscriptionchange", (event) => { + console.log("[ServiceWorker] PushSubscriptionChange"); + console.log(event); +}); + +self.addEventListener("push", (event) => { + console.log("[ServiceWorker] Received Web Push Event", { event }); + // server/types.go webPushPayload + const data = event.data.json(); + + const { formatted_title: formattedTitle, subscription_id: subscriptionId, message } = data; + broadcastChannel.postMessage(message); + + event.waitUntil( + (async () => { + const db = await getDbAsync(); + + await Promise.all([ + (async () => { + await db.notifications.add({ + ...message, + subscriptionId, + // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, + }); + const badgeCount = await db.notifications.where({ new: 1 }).count(); + console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); + self.navigator.setAppBadge?.(badgeCount); + })(), + db.subscriptions.update(subscriptionId, { + last: message.id, + }), + self.registration.showNotification(formattedTitle, { + tag: subscriptionId, + body: message.message, + icon: "/static/images/ntfy.png", + data, + }), + ]); + })() + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const { message } = event.notification.data; + + if (message.click) { + self.clients.openWindow(message.click); + return; + } + + const rootUrl = new URL(self.location.origin); + const topicUrl = new URL(message.topic, self.location.origin); + + event.waitUntil( + (async () => { + const clients = await self.clients.matchAll({ type: "window" }); + + const topicClient = clients.find((client) => client.url === topicUrl.toString()); + if (topicClient) { + topicClient.focus(); + return; + } + + const rootClient = clients.find((client) => client.url === rootUrl.toString()); + if (rootClient) { + rootClient.focus(); + return; + } + + self.clients.openWindow(topicUrl); + })() + ); +}); + +// self.__WB_MANIFEST is default injection point +// eslint-disable-next-line no-underscore-dangle +precacheAndRoute(self.__WB_MANIFEST); + +// clean old assets +cleanupOutdatedCaches(); + +// to allow work offline +registerRoute(new NavigationRoute(createHandlerBoundToURL("/"))); + +registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst()); diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 9576c4ec..572764fe 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -382,6 +382,10 @@ class AccountApi { setTimeout(() => this.runWorker(), delayMillis); } + stopWorker() { + clearTimeout(this.timer); + } + async runWorker() { if (!session.token()) { return; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index ba1cbe61..f731e61f 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -6,6 +6,9 @@ import { topicUrlAuth, topicUrlJsonPoll, topicUrlJsonPollWithSince, + topicUrlWebPushSubscribe, + topicUrlWebPushUnsubscribe, + webPushConfigUrl, } from "./utils"; import userManager from "./UserManager"; import { fetchOrThrow } from "./errors"; @@ -113,6 +116,62 @@ class Api { } throw new Error(`Unexpected server response ${response.status}`); } + + /** + * @returns {Promise<{ public_key: string } | undefined>} + */ + async getWebPushConfig(baseUrl) { + const response = await fetch(webPushConfigUrl(baseUrl)); + + if (response.ok) { + return response.json(); + } + + if (response.status === 404) { + // web push is not enabled + return undefined; + } + + throw new Error(`Unexpected server response ${response.status}`); + } + + async subscribeWebPush(baseUrl, topic, browserSubscription) { + const user = await userManager.get(baseUrl); + + const url = topicUrlWebPushSubscribe(baseUrl, topic); + console.log(`[Api] Sending Web Push Subscription ${url}`); + + const response = await fetch(url, { + method: "POST", + headers: maybeWithAuth({}, user), + body: JSON.stringify({ browser_subscription: browserSubscription }), + }); + + if (response.ok) { + return true; + } + + throw new Error(`Unexpected server response ${response.status}`); + } + + async unsubscribeWebPush(subscription) { + const user = await userManager.get(subscription.baseUrl); + + const url = topicUrlWebPushUnsubscribe(subscription.baseUrl, subscription.topic); + console.log(`[Api] Unsubscribing Web Push Subscription ${url}`); + + const response = await fetch(url, { + method: "POST", + headers: maybeWithAuth({}, user), + body: JSON.stringify({ endpoint: subscription.webPushEndpoint }), + }); + + if (response.ok) { + return true; + } + + throw new Error(`Unexpected server response ${response.status}`); + } } const api = new Api(); diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 2033cbea..952c74af 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,7 +1,8 @@ import Connection from "./Connection"; +import { NotificationType } from "./SubscriptionManager"; import { hashCode } from "./utils"; -const makeConnectionId = async (subscription, user) => +const makeConnectionId = (subscription, user) => user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); /** @@ -45,13 +46,19 @@ class ConnectionManager { return; } console.log(`[ConnectionManager] Refreshing connections`); - const subscriptionsWithUsersAndConnectionId = await Promise.all( - subscriptions.map(async (s) => { + const subscriptionsWithUsersAndConnectionId = subscriptions + .map((s) => { const [user] = users.filter((u) => u.baseUrl === s.baseUrl); - const connectionId = await makeConnectionId(s, user); + const connectionId = makeConnectionId(s, user); return { ...s, user, connectionId }; }) - ); + // we want to create a ws for both sound-only and active browser notifications, + // only background notifications don't need this as they come over web push. + // however, if background notifications are muted, we again need the ws while + // the page is active + .filter((s) => s.notificationType !== NotificationType.BACKGROUND && s.mutedUntil !== 1); + + console.log(); const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id)); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 45792dc8..a005f460 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,22 +1,18 @@ -import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils"; +import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; import prefs from "./Prefs"; -import subscriptionManager from "./SubscriptionManager"; import logo from "../img/ntfy.png"; +import api from "./Api"; /** * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { - async notify(subscriptionId, notification, onClickFallback) { + async notify(subscription, notification, onClickFallback) { if (!this.supported()) { return; } - const subscription = await subscriptionManager.get(subscriptionId); - const shouldNotify = await this.shouldNotify(subscription, notification); - if (!shouldNotify) { - return; - } + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); const displayName = topicDisplayName(subscription); const message = formatMessage(notification); @@ -26,6 +22,7 @@ class Notifier { console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); const n = new Notification(title, { body: message, + tag: subscription.id, icon: logo, }); if (notification.click) { @@ -33,45 +30,88 @@ class Notifier { } else { n.onclick = () => onClickFallback(subscription); } + } + async playSound() { // Play sound const sound = await prefs.sound(); if (sound && sound !== "none") { try { await playSound(sound); } catch (e) { - console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); + console.log(`[Notifier] Error playing audio`, e); } } } + 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(); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 372e46e5..2261dddc 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -18,6 +18,10 @@ class Poller { setTimeout(() => this.pollAll(), delayMillis); } + stopWorker() { + clearTimeout(this.timer); + } + async pollAll() { console.log(`[Poller] Polling all subscriptions`); const subscriptions = await subscriptionManager.all(); @@ -47,14 +51,13 @@ class Poller { } pollInBackground(subscription) { - const fn = async () => { + (async () => { try { await this.poll(subscription); } catch (e) { console.error(`[App] Error polling subscription ${subscription.id}`, e); } - }; - setTimeout(() => fn(), 0); + })(); } } diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 8adc5088..45078352 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -1,33 +1,45 @@ -import db from "./db"; +import getDb from "./getDb"; class Prefs { + constructor(db) { + this.db = db; + } + async setSound(sound) { - db.prefs.put({ key: "sound", value: sound.toString() }); + this.db.prefs.put({ key: "sound", value: sound.toString() }); } async sound() { - const sound = await db.prefs.get("sound"); + const sound = await this.db.prefs.get("sound"); return sound ? sound.value : "ding"; } async setMinPriority(minPriority) { - db.prefs.put({ key: "minPriority", value: minPriority.toString() }); + this.db.prefs.put({ key: "minPriority", value: minPriority.toString() }); } async minPriority() { - const minPriority = await db.prefs.get("minPriority"); + const minPriority = await this.db.prefs.get("minPriority"); return minPriority ? Number(minPriority.value) : 1; } async setDeleteAfter(deleteAfter) { - db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() }); + this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() }); } async deleteAfter() { - const deleteAfter = await db.prefs.get("deleteAfter"); + const deleteAfter = await this.db.prefs.get("deleteAfter"); return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week } + + async webPushDefaultEnabled() { + const obj = await this.db.prefs.get("webPushDefaultEnabled"); + return obj?.value ?? "initial"; + } + + async setWebPushDefaultEnabled(enabled) { + await this.db.prefs.put({ key: "webPushDefaultEnabled", value: enabled ? "enabled" : "disabled" }); + } } -const prefs = new Prefs(); -export default prefs; +export default new Prefs(getDb()); diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 498c1566..f9568a33 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -18,6 +18,10 @@ class Pruner { setTimeout(() => this.prune(), delayMillis); } + stopWorker() { + clearTimeout(this.timer); + } + async prune() { const deleteAfterSeconds = await prefs.deleteAfter(); const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds; diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 0b47f93a..8affa53c 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -1,12 +1,22 @@ +import sessionReplica from "./SessionReplica"; + class Session { + constructor(replica) { + this.replica = replica; + } + store(username, token) { localStorage.setItem("user", username); localStorage.setItem("token", token); + + this.replica.store(username, token); } reset() { localStorage.removeItem("user"); localStorage.removeItem("token"); + + this.replica.reset(); } resetAndRedirect(url) { @@ -27,5 +37,5 @@ class Session { } } -const session = new Session(); +const session = new Session(sessionReplica); export default session; diff --git a/web/src/app/SessionReplica.js b/web/src/app/SessionReplica.js new file mode 100644 index 00000000..808833f6 --- /dev/null +++ b/web/src/app/SessionReplica.js @@ -0,0 +1,44 @@ +import Dexie from "dexie"; + +// Store to IndexedDB as well so that the +// service worker can access it +// TODO: Probably make everything depend on this and not use localStorage, +// but that's a larger refactoring effort for another PR + +class SessionReplica { + constructor() { + const db = new Dexie("session-replica"); + + db.version(1).stores({ + keyValueStore: "&key", + }); + + this.db = db; + } + + async store(username, token) { + try { + await this.db.keyValueStore.bulkPut([ + { key: "user", value: username }, + { key: "token", value: token }, + ]); + } catch (e) { + console.error("[Session] Error replicating session to IndexedDB", e); + } + } + + async reset() { + try { + await this.db.delete(); + } catch (e) { + console.error("[Session] Error resetting session on IndexedDB", e); + } + } + + async username() { + return (await this.db.keyValueStore.get({ key: "user" }))?.value; + } +} + +const sessionReplica = new SessionReplica(); +export default sessionReplica; diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index ecbe4dac..ae4bf49e 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -1,47 +1,112 @@ -import db from "./db"; +import notifier from "./Notifier"; +import prefs from "./Prefs"; +import getDb from "./getDb"; import { topicUrl } from "./utils"; +/** @typedef {string} NotificationTypeEnum */ + +/** @enum {NotificationTypeEnum} */ +export const NotificationType = { + /** sound-only */ + SOUND: "sound", + /** browser notifications when there is an active tab, via websockets */ + BROWSER: "browser", + /** web push notifications, regardless of whether the window is open */ + BACKGROUND: "background", +}; + class SubscriptionManager { + constructor(db) { + this.db = db; + } + /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ async all() { - const subscriptions = await db.subscriptions.toArray(); + const subscriptions = await this.db.subscriptions.toArray(); return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), })) ); } async get(subscriptionId) { - return db.subscriptions.get(subscriptionId); + return this.db.subscriptions.get(subscriptionId); } - async 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()); diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index 2cdd5449..a3dee0a3 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -1,9 +1,13 @@ -import db from "./db"; +import getDb from "./getDb"; import session from "./Session"; class UserManager { + constructor(db) { + this.db = db; + } + async all() { - const users = await db.users.toArray(); + const users = await this.db.users.toArray(); if (session.exists()) { users.unshift(this.localUser()); } @@ -14,21 +18,21 @@ class UserManager { if (session.exists() && baseUrl === config.base_url) { return this.localUser(); } - return db.users.get(baseUrl); + return this.db.users.get(baseUrl); } async save(user) { if (session.exists() && user.baseUrl === config.base_url) { return; } - await db.users.put(user); + await this.db.users.put(user); } async delete(baseUrl) { if (session.exists() && baseUrl === config.base_url) { return; } - await db.users.delete(baseUrl); + await this.db.users.delete(baseUrl); } localUser() { @@ -43,5 +47,4 @@ class UserManager { } } -const userManager = new UserManager(); -export default userManager; +export default new UserManager(getDb()); diff --git a/web/src/app/WebPushWorker.js b/web/src/app/WebPushWorker.js new file mode 100644 index 00000000..508df725 --- /dev/null +++ b/web/src/app/WebPushWorker.js @@ -0,0 +1,46 @@ +import notifier from "./Notifier"; +import subscriptionManager from "./SubscriptionManager"; + +const onMessage = () => { + notifier.playSound(); +}; + +const delayMillis = 2000; // 2 seconds +const intervalMillis = 300000; // 5 minutes + +class WebPushWorker { + constructor() { + this.timer = null; + } + + startWorker() { + if (this.timer !== null) { + return; + } + + this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis); + setTimeout(() => this.updateSubscriptions(), delayMillis); + + this.broadcastChannel = new BroadcastChannel("web-push-broadcast"); + this.broadcastChannel.addEventListener("message", onMessage); + } + + stopWorker() { + clearTimeout(this.timer); + + this.broadcastChannel.removeEventListener("message", onMessage); + this.broadcastChannel.close(); + } + + async updateSubscriptions() { + try { + console.log("[WebPushBroadcastListener] Refreshing web push subscriptions"); + + await subscriptionManager.refreshWebPushSubscriptions(); + } catch (e) { + console.error("[WebPushBroadcastListener] Error refreshing web push subscriptions", e); + } + } +} + +export default new WebPushWorker(); diff --git a/web/src/app/db.js b/web/src/app/db.js deleted file mode 100644 index 0e1a5e71..00000000 --- a/web/src/app/db.js +++ /dev/null @@ -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; diff --git a/web/src/app/getDb.js b/web/src/app/getDb.js new file mode 100644 index 00000000..9cf8c66e --- /dev/null +++ b/web/src/app/getDb.js @@ -0,0 +1,34 @@ +import Dexie from "dexie"; +import session from "./Session"; +import sessionReplica from "./SessionReplica"; + +// Uses Dexie.js +// https://dexie.org/docs/API-Reference#quick-reference +// +// Notes: +// - As per docs, we only declare the indexable columns, not all columns + +const getDbBase = (username) => { + // The IndexedDB database name is based on the logged-in user + const dbName = username ? `ntfy-${username}` : "ntfy"; + const db = new Dexie(dbName); + + db.version(2).stores({ + subscriptions: "&id,baseUrl,notificationType", + notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance + users: "&baseUrl,username", + prefs: "&key", + }); + + return db; +}; + +export const getDbAsync = async () => { + const username = await sessionReplica.username(); + + return getDbBase(username); +}; + +const getDb = () => getDbBase(session.username()); + +export default getDb; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index ab7551bb..0f879373 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -20,7 +20,10 @@ export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/jso export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; +export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push`; +export const topicUrlWebPushUnsubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/unsubscribe`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); +export const webPushConfigUrl = (baseUrl) => `${baseUrl}/v1/web-push-config`; export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; @@ -156,7 +159,7 @@ export const splitNoEmpty = (s, delimiter) => .filter((x) => x !== ""); /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ -export const hashCode = async (s) => { +export const hashCode = (s) => { let hash = 0; for (let i = 0; i < s.length; i += 1) { const char = s.charCodeAt(i); @@ -288,3 +291,16 @@ export const randomAlphanumericString = (len) => { } return id; }; + +export const urlB64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; i += 1) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 541d4f86..bbc380c9 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -48,7 +48,7 @@ import routes from "./routes"; import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; -import db from "../app/db"; +import getDb from "../app/getDb"; import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import DialogFooter from "./DialogFooter"; @@ -57,6 +57,7 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import theme from "./theme"; import session from "../app/Session"; +import subscriptionManager from "../app/SubscriptionManager"; const Account = () => { if (!session.exists()) { @@ -1077,8 +1078,10 @@ const DeleteAccountDialog = (props) => { const handleSubmit = async () => { try { + await subscriptionManager.unsubscribeAllWebPush(); + await accountApi.delete(password); - await db.delete(); + await getDb().delete(); console.debug(`[Account] Account deleted`); session.resetAndRedirect(routes.app); } catch (e) { diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index 798efb49..154f17cb 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -13,7 +13,7 @@ import session from "../app/Session"; import logo from "../img/ntfy.svg"; import subscriptionManager from "../app/SubscriptionManager"; import routes from "./routes"; -import db from "../app/db"; +import getDb from "../app/getDb"; import { topicDisplayName } from "../app/utils"; import Navigation from "./Navigation"; import accountApi from "../app/AccountApi"; @@ -120,8 +120,10 @@ const ProfileIcon = () => { const handleLogout = async () => { try { + await subscriptionManager.unsubscribeAllWebPush(); + await accountApi.logout(); - await db.delete(); + await getDb().delete(); } finally { session.resetAndRedirect(routes.app); } diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 189235bb..148c3ac2 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -57,6 +57,10 @@ const App = () => { const updateTitle = (newNotificationsCount) => { document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; + + if ("setAppBadge" in window.navigator) { + window.navigator.setAppBadge(newNotificationsCount); + } }; const Layout = () => { diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 8cbefec4..b2755aa9 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -14,7 +14,6 @@ import { ListSubheader, Portal, Tooltip, - Button, Typography, Box, IconButton, @@ -94,15 +93,10 @@ const NavList = (props) => { setSubscribeDialogKey((prev) => prev + 1); }; - const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); - }; - const handleSubscribeSubmit = (subscription) => { console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); handleSubscribeReset(); navigate(routes.forSubscription(subscription)); - handleRequestNotificationPermission(); }; const handleAccountClick = () => { @@ -114,19 +108,27 @@ const NavList = (props) => { const isPaid = account?.billing?.subscription; const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; const showSubscriptionsList = props.subscriptions?.length > 0; - const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); + const showNotificationPermissionDenied = notifier.denied(); + const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired(); + const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported(); const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser - const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; + const navListPadding = - showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; + showNotificationPermissionDenied || + showNotificationIOSInstallRequired || + showNotificationBrowserNotSupportedBox || + showNotificationContextNotSupportedBox + ? "0" + : ""; return ( <> + {showNotificationPermissionDenied && } {showNotificationBrowserNotSupportedBox && } {showNotificationContextNotSupportedBox && } - {showNotificationGrantBox && } + {showNotificationIOSInstallRequired && } {!showSubscriptionsList && ( navigate(routes.app)} selected={location.pathname === config.app_root}> @@ -344,16 +346,26 @@ const SubscriptionItem = (props) => { ); }; -const NotificationGrantAlert = (props) => { +const NotificationPermissionDeniedAlert = () => { const { t } = useTranslation(); return ( <> - {t("alert_grant_title")} - {t("alert_grant_description")} - + {t("alert_notification_permission_denied_title")} + {t("alert_notification_permission_denied_description")} + + + + ); +}; + +const NotificationIOSInstallRequiredAlert = () => { + const { t } = useTranslation(); + return ( + <> + + {t("alert_notification_ios_install_required_title")} + {t("alert_notification_ios_install_required_description")} diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 4afc0f80..091e1f51 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; +import notifier from "../app/Notifier"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -85,6 +86,7 @@ const Notifications = () => { + {notifier.pushSupported() && } ); @@ -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 ( + + + + + + ); +}; + const Users = () => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); diff --git a/web/src/components/SubscribeDialog.jsx b/web/src/components/SubscribeDialog.jsx index 0f1cec13..57281661 100644 --- a/web/src/components/SubscribeDialog.jsx +++ b/web/src/components/SubscribeDialog.jsx @@ -8,17 +8,20 @@ import { DialogContentText, DialogTitle, Autocomplete, - Checkbox, FormControlLabel, FormGroup, useMediaQuery, + Switch, + Stack, } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { Warning } from "@mui/icons-material"; +import { useLiveQuery } from "dexie-react-hooks"; import theme from "./theme"; import api from "../app/Api"; import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; import userManager from "../app/UserManager"; -import subscriptionManager from "../app/SubscriptionManager"; +import subscriptionManager, { NotificationType } from "../app/SubscriptionManager"; import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; import session from "../app/Session"; @@ -28,11 +31,13 @@ import ReserveTopicSelect from "./ReserveTopicSelect"; import { AccountContext } from "./App"; import { TopicReservedError, UnauthorizedError } from "../app/errors"; import { ReserveLimitChip } from "./SubscriptionPopup"; +import notifier from "../app/Notifier"; +import prefs from "../app/Prefs"; const publicBaseUrl = "https://ntfy.sh"; -export const subscribeTopic = async (baseUrl, topic) => { - const subscription = await subscriptionManager.add(baseUrl, topic); +export const subscribeTopic = async (baseUrl, topic, opts) => { + const subscription = await subscriptionManager.add(baseUrl, topic, opts); if (session.exists()) { try { await accountApi.addSubscription(baseUrl, topic); @@ -52,14 +57,29 @@ const SubscribeDialog = (props) => { const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSuccess = async () => { + const webPushDefaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled()); + + const handleSuccess = async (notificationType) => { console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); const actualBaseUrl = baseUrl || config.base_url; - const subscription = await subscribeTopic(actualBaseUrl, topic); + const subscription = await subscribeTopic(actualBaseUrl, topic, { + notificationType, + }); poller.pollInBackground(subscription); // Dangle! + + // if the user hasn't changed the default web push setting yet, set it to enabled + if (notificationType === "background" && webPushDefaultEnabled === "initial") { + await prefs.setWebPushDefaultEnabled(true); + } + props.onSuccess(subscription); }; + // wait for liveQuery load + if (webPushDefaultEnabled === undefined) { + return <>; + } + return ( {!showLoginPage && ( @@ -72,6 +92,7 @@ const SubscribeDialog = (props) => { onCancel={props.onCancel} onNeedsLogin={() => setShowLoginPage(true)} onSuccess={handleSuccess} + webPushDefaultEnabled={webPushDefaultEnabled} /> )} {showLoginPage && 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) => { setReserveTopicVisible(ev.target.checked)} @@ -217,8 +280,9 @@ const SubscribePage = (props) => { { )} )} + {browserNotificationsSupported && ( + + + } + label={ + + {t("subscribe_dialog_subscribe_enable_browser_notifications_label")} + {notificationsExplicitlyDenied && } + + } + /> + {pushNotificationsSupported && !anotherServerVisible && browserNotificationsEnabled && ( + + } + label={t("subscribe_dialog_subscribe_enable_background_notifications_label")} + /> + )} + + )} diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index ee83a119..90c63b3f 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -14,12 +14,26 @@ import { useMediaQuery, MenuItem, IconButton, + ListItemIcon, + ListItemText, + Divider, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Clear } from "@mui/icons-material"; +import { + Check, + Clear, + ClearAll, + Edit, + EnhancedEncryption, + Lock, + LockOpen, + NotificationsOff, + RemoveCircle, + Send, +} from "@mui/icons-material"; import theme from "./theme"; -import subscriptionManager from "../app/SubscriptionManager"; +import subscriptionManager, { NotificationType } from "../app/SubscriptionManager"; import DialogFooter from "./DialogFooter"; import accountApi, { Role } from "../app/AccountApi"; import session from "../app/Session"; @@ -30,6 +44,7 @@ import api from "../app/Api"; import { AccountContext } from "./App"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; +import notifier from "../app/Notifier"; export const SubscriptionPopup = (props) => { const { t } = useTranslation(); @@ -70,8 +85,7 @@ export const SubscriptionPopup = (props) => { }; const handleSendTestMessage = async () => { - const { baseUrl } = props.subscription; - const { topic } = props.subscription; + const { baseUrl, topic } = props.subscription; const tags = shuffle([ "grinning", "octopus", @@ -133,7 +147,7 @@ export const SubscriptionPopup = (props) => { const handleUnsubscribe = async () => { console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); - await subscriptionManager.remove(props.subscription.id); + await subscriptionManager.remove(props.subscription); if (session.exists() && !subscription.internal) { try { await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); @@ -155,19 +169,72 @@ export const SubscriptionPopup = (props) => { return ( <> - {t("action_bar_change_display_name")} - {showReservationAdd && {t("action_bar_reservation_add")}} + + + + + + + + {t("action_bar_change_display_name")} + + {showReservationAdd && ( + + + + + {t("action_bar_reservation_add")} + + )} {showReservationAddDisabled && ( + + + + {t("action_bar_reservation_add")} )} - {showReservationEdit && {t("action_bar_reservation_edit")}} - {showReservationDelete && {t("action_bar_reservation_delete")}} - {t("action_bar_send_test_notification")} - {t("action_bar_clear_notifications")} - {t("action_bar_unsubscribe")} + {showReservationEdit && ( + + + + + + {t("action_bar_reservation_edit")} + + )} + {showReservationDelete && ( + + + + + + {t("action_bar_reservation_delete")} + + )} + + + + + + {t("action_bar_send_test_notification")} + + + + + + + {t("action_bar_clear_notifications")} + + + + + + + {t("action_bar_unsubscribe")} + { ); }; +const getNotificationType = (subscription) => { + if (subscription.mutedUntil === 1) { + return "muted"; + } + + return subscription.notificationType ?? NotificationType.BROWSER; +}; + +const checkedItem = ( + + + +); + +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 ( + + + + + {t("notification_toggle_unmute")} + + ); + } + + return ( + <> + + {type === NotificationType.SOUND && checkedItem} + handleChange(NotificationType.SOUND)}> + {t("notification_toggle_sound")} + + + {!notifier.denied() && !notifier.iosSupportedButInstallRequired() && ( + <> + {notifier.supported() && ( + + {type === NotificationType.BROWSER && checkedItem} + handleChange(NotificationType.BROWSER)}> + {t("notification_toggle_browser")} + + + )} + {notifier.pushSupported() && ( + + {type === NotificationType.BACKGROUND && checkedItem} + handleChange(NotificationType.BACKGROUND)}> + {t("notification_toggle_background")} + + + )} + + )} + + ); +}; + export const ReserveLimitChip = () => { const { account } = useContext(AccountContext); if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 6b681881..3a710e3a 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -2,7 +2,6 @@ import { useNavigate, useParams } from "react-router-dom"; import { useEffect, useState } from "react"; import subscriptionManager from "../app/SubscriptionManager"; import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; -import notifier from "../app/Notifier"; import routes from "./routes"; import connectionManager from "../app/ConnectionManager"; import poller from "../app/Poller"; @@ -10,6 +9,7 @@ import pruner from "../app/Pruner"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; +import webPushWorker from "../app/WebPushWorker"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -41,7 +41,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); - await notifier.notify(subscriptionId, notification, defaultClickAction); + await subscriptionManager.notify(subscriptionId, notification, defaultClickAction); } }; @@ -61,7 +61,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { } }; - connectionManager.registerStateListener(subscriptionManager.updateState); + connectionManager.registerStateListener((id, state) => subscriptionManager.updateState(id, state)); connectionManager.registerMessageListener(handleMessage); return () => { @@ -79,7 +79,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { if (!account || !account.sync_topic) { return; } - subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! + subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle! }, [account]); // When subscriptions or users change, refresh the connections @@ -129,11 +129,30 @@ export const useAutoSubscribe = (subscriptions, selected) => { * and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186. */ + +const stopWorkers = () => { + poller.stopWorker(); + pruner.stopWorker(); + accountApi.stopWorker(); +}; + +const startWorkers = () => { + poller.startWorker(); + pruner.startWorker(); + accountApi.startWorker(); +}; + export const useBackgroundProcesses = () => { useEffect(() => { - poller.startWorker(); - pruner.startWorker(); - accountApi.startWorker(); + console.log("[useBackgroundProcesses] mounting"); + startWorkers(); + webPushWorker.startWorker(); + + return () => { + console.log("[useBackgroundProcesses] unloading"); + stopWorkers(); + webPushWorker.stopWorker(); + }; }, []); }; diff --git a/web/vite.config.js b/web/vite.config.js index ffc80ab7..840ee006 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,14 +1,73 @@ /* eslint-disable import/no-extraneous-dependencies */ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; + +// please look at develop.md for how to run your browser +// in a mode allowing insecure service worker testing +// this turns on: +// - the service worker in dev mode +// - turns off automatically opening the browser +const enableLocalPWATesting = process.env.ENABLE_DEV_PWA; export default defineConfig(() => ({ build: { outDir: "build", assetsDir: "static/media", + sourcemap: true, }, server: { port: 3000, + open: !enableLocalPWATesting, }, - plugins: [react()], + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + injectRegister: "inline", + strategies: "injectManifest", + devOptions: { + enabled: enableLocalPWATesting, + /* when using generateSW the PWA plugin will switch to classic */ + type: "module", + navigateFallback: "index.html", + }, + injectManifest: { + globPatterns: ["**/*.{js,css,html,mp3,png,svg,json}"], + globIgnores: ["config.js"], + manifestTransforms: [ + (entries) => ({ + manifest: entries.map((entry) => + entry.url === "index.html" + ? { + ...entry, + url: "/", + } + : entry + ), + }), + ], + }, + manifest: { + name: "ntfy web", + short_name: "ntfy", + description: + "ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy.", + theme_color: "#317f6f", + start_url: "/", + icons: [ + { + src: "/static/images/pwa-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/static/images/pwa-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }, + }), + ], }));