mirror of
https://github.com/binwiederhier/ntfy.git
synced 2024-12-22 16:31:33 +03:00
Add PWA, service worker and Web Push
- Use new notification request/opt-in flow for push - Implement unsubscribing - Implement muting - Implement emojis in title - Add iOS specific PWA warning - Don’t use websockets when web push is enabled - Fix duplicate notifications - Implement default web push setting - Implement changing subscription type - Implement web push subscription refresh - Implement web push notification click
This commit is contained in:
parent
733ef4664b
commit
ff5c854192
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@ secrets/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
__pycache__
|
__pycache__
|
||||||
|
web/dev-dist/
|
17
cmd/serve.go
17
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.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: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "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{
|
var cmdServe = &cli.Command{
|
||||||
@ -129,6 +134,11 @@ func execServe(c *cli.Context) error {
|
|||||||
keyFile := c.String("key-file")
|
keyFile := c.String("key-file")
|
||||||
certFile := c.String("cert-file")
|
certFile := c.String("cert-file")
|
||||||
firebaseKeyFile := c.String("firebase-key-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")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDuration := c.Duration("cache-duration")
|
||||||
cacheStartupQueries := c.String("cache-startup-queries")
|
cacheStartupQueries := c.String("cache-startup-queries")
|
||||||
@ -183,6 +193,8 @@ func execServe(c *cli.Context) error {
|
|||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
return errors.New("if set, FCM key file must exist")
|
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 {
|
} else if keepaliveInterval < 5*time.Second {
|
||||||
return errors.New("keepalive interval cannot be lower than five seconds")
|
return errors.New("keepalive interval cannot be lower than five seconds")
|
||||||
} else if managerInterval < 5*time.Second {
|
} else if managerInterval < 5*time.Second {
|
||||||
@ -347,6 +359,11 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.MetricsListenHTTP = metricsListenHTTP
|
conf.MetricsListenHTTP = metricsListenHTTP
|
||||||
conf.ProfileListenHTTP = profileListenHTTP
|
conf.ProfileListenHTTP = profileListenHTTP
|
||||||
conf.Version = c.App.Version
|
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
|
// Set up hot-reloading of config
|
||||||
go sigHandlerConfigReload(config)
|
go sigHandlerConfigReload(config)
|
||||||
|
39
cmd/web_push.go
Normal file
39
cmd/web_push.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
//go:build !noserver
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands = append(commands, cmdWebPush)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdWebPush = &cli.Command{
|
||||||
|
Name: "web-push-keys",
|
||||||
|
Usage: "Generate web push VAPID keys",
|
||||||
|
UsageText: "ntfy web-push-keys",
|
||||||
|
Category: categoryServer,
|
||||||
|
Action: generateWebPushKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateWebPushKeys(c *cli.Context) error {
|
||||||
|
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, `Add the following lines to your config file:
|
||||||
|
web-push-enabled: true
|
||||||
|
web-push-public-key: %s
|
||||||
|
web-push-private-key: %s
|
||||||
|
web-push-subscriptions-file: <filename>
|
||||||
|
web-push-email-address: <email address>
|
||||||
|
`, publicKey, privateKey)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -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-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 |
|
| `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 |
|
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||||
|
| `web-push-enabled` | `NTFY_WEB_PUSH_ENABLED` | *boolean* (`true` or `false`) | - | Web Push: Enable/disable (requires private and public key below). |
|
||||||
|
| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy web-push-keys` to generate |
|
||||||
|
| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push-keys` to generate |
|
||||||
|
| `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file |
|
||||||
|
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
|
||||||
## Command line options
|
## Command line options
|
||||||
```
|
```
|
||||||
$ ntfy serve --help
|
|
||||||
NAME:
|
NAME:
|
||||||
ntfy serve - Run the ntfy server
|
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]
|
--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]
|
--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]
|
--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-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 to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--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 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]
|
--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]
|
--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]
|
--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]
|
--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]
|
--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-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-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]
|
--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-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-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-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]
|
--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-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-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]
|
--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]
|
--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-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]
|
--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-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-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-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]
|
--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-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]
|
--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]
|
--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)
|
--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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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/),
|
* **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
|
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.
|
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`)
|
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*).
|
and install all the 100,000 dependencies (*sigh*).
|
||||||
|
|
||||||
@ -241,6 +241,67 @@ $ cd web
|
|||||||
$ npm start
|
$ npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Testing Web Push locally
|
||||||
|
|
||||||
|
Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-service-workers-via-http>
|
||||||
|
|
||||||
|
#### With the dev servers
|
||||||
|
|
||||||
|
1. Get web push keys `go run main.go web-push-keys`
|
||||||
|
|
||||||
|
2. Run the server with web push enabled
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run main.go \
|
||||||
|
--log-level debug \
|
||||||
|
serve \
|
||||||
|
--web-push-enabled \
|
||||||
|
--web-push-public-key KEY \
|
||||||
|
--web-push-private-key KEY \
|
||||||
|
--web-push-subscriptions-file=/tmp/subscriptions.db
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `web/public/config.js` set `base_url` to `http://localhost`. This is required as web push can only be used
|
||||||
|
with the server matching the `base_url`
|
||||||
|
|
||||||
|
4. Run `ENABLE_DEV_PWA=1 npm run start` - this enables the dev service worker
|
||||||
|
|
||||||
|
5. Set your browser to allow testing service workers insecurely:
|
||||||
|
|
||||||
|
- Chrome:
|
||||||
|
|
||||||
|
Open Chrome with special flags allowing insecure localhost service worker testing (regularly dismissing SSL warnings is not enough)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# for example, macOS
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||||
|
--user-data-dir=/tmp/foo \
|
||||||
|
--unsafely-treat-insecure-origin-as-secure=http://localhost:3000,http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
- Firefox:
|
||||||
|
|
||||||
|
See here: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
|
||||||
|
|
||||||
|
> Note: On Firefox, for testing you can run service workers over HTTP (insecurely); simply check the Enable Service Workers over HTTP (when toolbox is open) option in the Firefox Devtools options/gear menu
|
||||||
|
|
||||||
|
- Safari, iOS:
|
||||||
|
|
||||||
|
There doesn't seem to be a good way to do this currently. The only way is to serve a valid HTTPS certificate.
|
||||||
|
|
||||||
|
This is beyond the scope of this guide, but you can try `mkcert`, a number of reverse proxies such as Traefik and Caddy,
|
||||||
|
or tunneling software such as [Cloudflare Tunnels][cloudflare_tunnels] or ngrok.
|
||||||
|
|
||||||
|
[cloudflare_tunnels]: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/do-more-with-tunnels/trycloudflare/
|
||||||
|
|
||||||
|
6. Open <http://localhost:3000/>
|
||||||
|
#### With a built package
|
||||||
|
|
||||||
|
1. Run `make web-build`
|
||||||
|
|
||||||
|
2. Follow steps 1, 2, 4 and 5 from "With the dev servers"
|
||||||
|
|
||||||
|
3. Open <http://localhost/>
|
||||||
### Build the docs
|
### 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
|
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:
|
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||||
|
2
go.mod
2
go.mod
@ -39,10 +39,12 @@ require (
|
|||||||
cloud.google.com/go/longrunning v0.5.0 // indirect
|
cloud.google.com/go/longrunning v0.5.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // 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-jwt/jwt/v4 v4.5.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
|
5
go.sum
5
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/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 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
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/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.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 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
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 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
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=
|
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-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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines default config settings (excluding limits, see below)
|
// Defines default config settings (excluding limits, see below)
|
||||||
@ -146,6 +147,11 @@ type Config struct {
|
|||||||
EnableMetrics bool
|
EnableMetrics bool
|
||||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
Version string // injected by App
|
Version string // injected by App
|
||||||
|
WebPushEnabled bool
|
||||||
|
WebPushPrivateKey string
|
||||||
|
WebPushPublicKey string
|
||||||
|
WebPushSubscriptionsFile string
|
||||||
|
WebPushEmailAddress string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
@ -227,5 +233,8 @@ func NewConfig() *Config {
|
|||||||
EnableReservations: false,
|
EnableReservations: false,
|
||||||
AccessControlAllowOrigin: "*",
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
Version: "",
|
||||||
|
WebPushPrivateKey: "",
|
||||||
|
WebPushPublicKey: "",
|
||||||
|
WebPushSubscriptionsFile: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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}
|
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}
|
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}
|
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}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
290
server/server.go
290
server/server.go
@ -9,13 +9,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -32,32 +25,43 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"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
|
// Server is the main server, providing the UI and API for ntfy
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *Config
|
config *Config
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
httpsServer *http.Server
|
httpsServer *http.Server
|
||||||
httpMetricsServer *http.Server
|
httpMetricsServer *http.Server
|
||||||
httpProfileServer *http.Server
|
httpProfileServer *http.Server
|
||||||
unixListener net.Listener
|
unixListener net.Listener
|
||||||
smtpServer *smtp.Server
|
smtpServer *smtp.Server
|
||||||
smtpServerBackend *smtpBackend
|
smtpServerBackend *smtpBackend
|
||||||
smtpSender mailer
|
smtpSender mailer
|
||||||
topics map[string]*topic
|
topics map[string]*topic
|
||||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||||
firebaseClient *firebaseClient
|
firebaseClient *firebaseClient
|
||||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||||
userManager *user.Manager // Might be nil!
|
userManager *user.Manager // Might be nil!
|
||||||
messageCache *messageCache // Database that stores the messages
|
messageCache *messageCache // Database that stores the messages
|
||||||
fileCache *fileCache // File system based cache that stores attachments
|
webPushSubscriptionStore *webPushSubscriptionStore // Database that stores web push subscriptions
|
||||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
fileCache *fileCache // File system based cache that stores attachments
|
||||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||||
closeChan chan bool
|
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||||
mu sync.RWMutex
|
closeChan chan bool
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
// 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 (
|
var (
|
||||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
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!
|
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
|
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$`)
|
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$`)
|
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$`)
|
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$`)
|
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$`)
|
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)$`)
|
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"
|
webConfigPath = "/config.js"
|
||||||
|
webManifestPath = "/manifest.webmanifest"
|
||||||
|
webServiceWorkerPath = "/sw.js"
|
||||||
accountPath = "/account"
|
accountPath = "/account"
|
||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
@ -98,6 +106,7 @@ var (
|
|||||||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||||
|
apiWebPushConfig = "/v1/web-push-config"
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
webPushSubscriptionStore, err := createWebPushSubscriptionStore(conf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
topics, err := messageCache.Topics()
|
topics, err := messageCache.Topics()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -188,17 +201,18 @@ func New(conf *Config) (*Server, error) {
|
|||||||
firebaseClient = newFirebaseClient(sender, auther)
|
firebaseClient = newFirebaseClient(sender, auther)
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
fileCache: fileCache,
|
webPushSubscriptionStore: webPushSubscriptionStore,
|
||||||
firebaseClient: firebaseClient,
|
fileCache: fileCache,
|
||||||
smtpSender: mailer,
|
firebaseClient: firebaseClient,
|
||||||
topics: topics,
|
smtpSender: mailer,
|
||||||
userManager: userManager,
|
topics: topics,
|
||||||
messages: messages,
|
userManager: userManager,
|
||||||
messagesHistory: []int64{messages},
|
messages: messages,
|
||||||
visitors: make(map[string]*visitor),
|
messagesHistory: []int64{messages},
|
||||||
stripe: stripe,
|
visitors: make(map[string]*visitor),
|
||||||
|
stripe: stripe,
|
||||||
}
|
}
|
||||||
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
||||||
return s, nil
|
return s, nil
|
||||||
@ -213,6 +227,14 @@ func createMessageCache(conf *Config) (*messageCache, error) {
|
|||||||
return newMemCache()
|
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
|
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
|
||||||
// a manager go routine to print stats and prune messages.
|
// a manager go routine to print stats and prune messages.
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
@ -342,6 +364,9 @@ func (s *Server) closeDatabases() {
|
|||||||
s.userManager.Close()
|
s.userManager.Close()
|
||||||
}
|
}
|
||||||
s.messageCache.Close()
|
s.messageCache.Close()
|
||||||
|
if s.webPushSubscriptionStore != nil {
|
||||||
|
s.webPushSubscriptionStore.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle is the main entry point for all HTTP requests
|
// 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)
|
return s.handleHealth(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
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 {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
||||||
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
|
} 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)
|
return s.handleStats(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
||||||
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
|
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 {
|
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||||
return s.handleMatrixDiscovery(w)
|
return s.handleMatrixDiscovery(w)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
} 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)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
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)) {
|
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
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())
|
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 {
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
response := &apiHealthResponse{
|
response := &apiHealthResponse{
|
||||||
Healthy: true,
|
Healthy: true,
|
||||||
@ -564,6 +656,11 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
return err
|
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,
|
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||||
// and listen-metrics-http is not set.
|
// and listen-metrics-http is not set.
|
||||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
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
|
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
|
||||||
go s.forwardPollRequest(v, m)
|
go s.forwardPollRequest(v, m)
|
||||||
}
|
}
|
||||||
|
if s.config.WebPushEnabled {
|
||||||
|
go s.publishToWebPushEndpoints(v, m)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
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) {
|
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")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
@ -1692,6 +1881,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
|||||||
if s.config.UpstreamBaseURL != "" {
|
if s.config.UpstreamBaseURL != "" {
|
||||||
go s.forwardPollRequest(v, m)
|
go s.forwardPollRequest(v, m)
|
||||||
}
|
}
|
||||||
|
if s.config.WebPushEnabled {
|
||||||
|
go s.publishToWebPushEndpoints(v, m)
|
||||||
|
}
|
||||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,16 @@
|
|||||||
#
|
#
|
||||||
# firebase-key-file: <filename>
|
# firebase-key-file: <filename>
|
||||||
|
|
||||||
|
# Enable web push
|
||||||
|
#
|
||||||
|
# Run ntfy web-push-keys to generate the keys
|
||||||
|
#
|
||||||
|
# web-push-enabled: true
|
||||||
|
# web-push-public-key: ""
|
||||||
|
# web-push-private-key: ""
|
||||||
|
# web-push-subscriptions-file: ""
|
||||||
|
# web-push-email-address: ""
|
||||||
|
|
||||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
# 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.
|
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||||
#
|
#
|
||||||
|
@ -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 {
|
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
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 != "" {
|
if u.Billing.StripeSubscriptionID != "" {
|
||||||
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
|
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
|
||||||
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
|
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
|
||||||
|
@ -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 {
|
func (s *Server) ensureUserManager(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
|
@ -238,6 +238,12 @@ func TestServer_WebEnabled(t *testing.T) {
|
|||||||
rr = request(t, s, "GET", "/config.js", "", nil)
|
rr = request(t, s, "GET", "/config.js", "", nil)
|
||||||
require.Equal(t, 404, rr.Code)
|
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)
|
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||||
require.Equal(t, 404, rr.Code)
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
@ -250,6 +256,13 @@ func TestServer_WebEnabled(t *testing.T) {
|
|||||||
|
|
||||||
rr = request(t, s2, "GET", "/config.js", "", nil)
|
rr = request(t, s2, "GET", "/config.js", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
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) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed" // required by go:embed
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
@ -130,25 +128,3 @@ This message was sent by {ip} at {time} via {topicURL}`
|
|||||||
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
||||||
return body, nil
|
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
|
|
||||||
}
|
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -401,6 +402,10 @@ type apiConfigResponse struct {
|
|||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiWebPushConfigResponse struct {
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountBillingPrices struct {
|
type apiAccountBillingPrices struct {
|
||||||
Month int64 `json:"month"`
|
Month int64 `json:"month"`
|
||||||
Year int64 `json:"year"`
|
Year int64 `json:"year"`
|
||||||
@ -462,3 +467,22 @@ type apiStripeSubscriptionDeletedEvent struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Customer string `json:"customer"`
|
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"`
|
||||||
|
}
|
||||||
|
@ -2,6 +2,8 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
_ "embed" // required by go:embed
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
@ -133,3 +135,25 @@ func maybeDecodeHeader(header string) string {
|
|||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed "mailer_emoji_map.json"
|
||||||
|
emojisJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||||
|
var emojiMap map[string]string
|
||||||
|
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tagsOut = make([]string, 0)
|
||||||
|
emojisOut = make([]string, 0)
|
||||||
|
for _, t := range tags {
|
||||||
|
if emoji, ok := emojiMap[t]; ok {
|
||||||
|
emojisOut = append(emojisOut, emoji)
|
||||||
|
} else {
|
||||||
|
tagsOut = append(tagsOut, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
132
server/web_push.go
Normal file
132
server/web_push.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// Messages cache
|
||||||
|
const (
|
||||||
|
createWebPushSubscriptionsTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS web_push_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
key_auth TEXT NOT NULL,
|
||||||
|
key_p256dh TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON web_push_subscriptions (topic);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoint ON web_push_subscriptions (endpoint);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON web_push_subscriptions (topic, endpoint);
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
insertWebPushSubscriptionQuery = `
|
||||||
|
INSERT OR REPLACE INTO web_push_subscriptions (topic, username, endpoint, key_auth, key_p256dh)
|
||||||
|
VALUES (?, ?, ?, ?, ?);
|
||||||
|
`
|
||||||
|
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM web_push_subscriptions WHERE endpoint = ?`
|
||||||
|
deleteWebPushSubscriptionByUsernameQuery = `DELETE FROM web_push_subscriptions WHERE username = ?`
|
||||||
|
deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM web_push_subscriptions WHERE topic = ? AND endpoint = ?`
|
||||||
|
|
||||||
|
selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, username FROM web_push_subscriptions WHERE topic = ?`
|
||||||
|
|
||||||
|
selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM web_push_subscriptions`
|
||||||
|
)
|
||||||
|
|
||||||
|
type webPushSubscriptionStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebPushSubscriptionStore(filename string) (*webPushSubscriptionStore, error) {
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := setupSubscriptionDb(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webPushSubscriptionStore := &webPushSubscriptionStore{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
return webPushSubscriptionStore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSubscriptionDb(db *sql.DB) error {
|
||||||
|
// If 'messages' table does not exist, this must be a new database
|
||||||
|
rowsMC, err := db.Query(selectWebPushSubscriptionsCountQuery)
|
||||||
|
if err != nil {
|
||||||
|
return setupNewSubscriptionDb(db)
|
||||||
|
}
|
||||||
|
rowsMC.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNewSubscriptionDb(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webPushSubscriptionStore) AddSubscription(topic string, username string, subscription webPushSubscribePayload) error {
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
insertWebPushSubscriptionQuery,
|
||||||
|
topic,
|
||||||
|
username,
|
||||||
|
subscription.BrowserSubscription.Endpoint,
|
||||||
|
subscription.BrowserSubscription.Keys.Auth,
|
||||||
|
subscription.BrowserSubscription.Keys.P256dh,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webPushSubscriptionStore) RemoveSubscription(topic string, endpoint string) error {
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
deleteWebPushSubscriptionByTopicAndEndpointQuery,
|
||||||
|
topic,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webPushSubscriptionStore) GetSubscriptionsForTopic(topic string) (subscriptions []webPushSubscription, err error) {
|
||||||
|
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
data := []webPushSubscription{}
|
||||||
|
for rows.Next() {
|
||||||
|
i := webPushSubscription{}
|
||||||
|
err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh, &i.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, i)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webPushSubscriptionStore) ExpireWebPushEndpoint(endpoint string) error {
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
deleteWebPushSubscriptionByEndpointQuery,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webPushSubscriptionStore) ExpireWebPushForUser(username string) error {
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
deleteWebPushSubscriptionByUsernameQuery,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (c *webPushSubscriptionStore) Close() error {
|
||||||
|
return c.db.Close()
|
||||||
|
}
|
@ -33,5 +33,6 @@
|
|||||||
"unnamedComponents": "arrow-function"
|
"unnamedComponents": "arrow-function"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
|
||||||
}
|
}
|
||||||
|
@ -13,11 +13,18 @@
|
|||||||
<meta name="theme-color" content="#317f6f" />
|
<meta name="theme-color" content="#317f6f" />
|
||||||
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||||
|
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
|
||||||
|
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
|
||||||
|
|
||||||
<!-- Favicon, see favicon.io -->
|
<!-- Favicon, see favicon.io -->
|
||||||
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
||||||
|
|
||||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
|
||||||
|
/>
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta property="og:site_name" content="ntfy web" />
|
<meta property="og:site_name" content="ntfy web" />
|
||||||
|
2652
web/package-lock.json
generated
2652
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -40,7 +40,8 @@
|
|||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"vite": "^4.3.9"
|
"vite": "^4.3.9",
|
||||||
|
"vite-plugin-pwa": "^0.15.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
base_url: window.location.origin, // Change to test against a different server
|
base_url: window.location.origin, // Change to test against a different server
|
||||||
app_root: "/app",
|
app_root: "/",
|
||||||
enable_login: true,
|
enable_login: true,
|
||||||
enable_signup: true,
|
enable_signup: true,
|
||||||
enable_payments: false,
|
enable_payments: false,
|
||||||
|
BIN
web/public/static/images/apple-touch-icon.png
Normal file
BIN
web/public/static/images/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
20
web/public/static/images/mask-icon.svg
Normal file
20
web/public/static/images/mask-icon.svg
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1546 6263 c-1 -1 -132 -3 -292 -4 -301 -1 -353 -7 -484 -50 -265
|
||||||
|
-88 -483 -296 -578 -550 -52 -140 -54 -172 -53 -784 2 -2183 1 -3783 -3 -3802
|
||||||
|
-2 -12 -7 -49 -11 -82 -3 -33 -7 -68 -9 -78 -2 -10 -7 -45 -12 -78 -4 -33 -8
|
||||||
|
-62 -9 -65 0 -3 -5 -36 -10 -75 -5 -38 -9 -72 -10 -75 -1 -3 -5 -34 -10 -70
|
||||||
|
-12 -98 -12 -96 -30 -225 -9 -66 -19 -123 -21 -127 -15 -24 16 -17 686 162
|
||||||
|
107 29 200 53 205 54 6 2 30 8 55 15 25 7 140 37 255 68 116 30 282 75 370 98
|
||||||
|
l160 43 2175 0 c1196 0 2201 3 2234 7 210 21 414 120 572 279 118 119 188 237
|
||||||
|
236 403 l23 78 2 2025 2 2025 -25 99 c-23 94 -87 247 -116 277 -7 8 -26 33
|
||||||
|
-41 56 -97 142 -326 296 -512 342 -27 7 -59 15 -70 18 -11 3 -94 7 -185 10
|
||||||
|
-165 4 -4490 10 -4494 6z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
BIN
web/public/static/images/pwa-192x192.png
Normal file
BIN
web/public/static/images/pwa-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
BIN
web/public/static/images/pwa-512x512.png
Normal file
BIN
web/public/static/images/pwa-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
@ -52,9 +52,10 @@
|
|||||||
"nav_button_connecting": "connecting",
|
"nav_button_connecting": "connecting",
|
||||||
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
|
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
|
||||||
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
|
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
|
||||||
"alert_grant_title": "Notifications are disabled",
|
"alert_notification_permission_denied_title": "Notifications are blocked",
|
||||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
"alert_notification_permission_denied_description": "Please re-enable them in your browser and refresh the page to receive notifications",
|
||||||
"alert_grant_button": "Grant now",
|
"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_title": "Notifications not supported",
|
||||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
||||||
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
|
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
|
||||||
@ -92,6 +93,10 @@
|
|||||||
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
|
"notifications_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_example": "Example",
|
||||||
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
||||||
|
"notification_toggle_unmute": "Unmute",
|
||||||
|
"notification_toggle_sound": "Sound only",
|
||||||
|
"notification_toggle_browser": "Browser notifications",
|
||||||
|
"notification_toggle_background": "Browser and background notifications",
|
||||||
"display_name_dialog_title": "Change display name",
|
"display_name_dialog_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_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",
|
"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_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_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Use another server",
|
"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_base_url_label": "Service URL",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generate name",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Generate name",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Cancel",
|
"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_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_topic_label": "Topic",
|
||||||
"prefs_reservations_dialog_access_label": "Access",
|
"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_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_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.",
|
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",
|
||||||
|
111
web/public/sw.js
Normal file
111
web/public/sw.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
|
||||||
|
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||||
|
import { NetworkFirst } from "workbox-strategies";
|
||||||
|
|
||||||
|
import { getDbAsync } from "../src/app/getDb";
|
||||||
|
|
||||||
|
// See WebPushWorker, this is to play a sound on supported browsers,
|
||||||
|
// if the app is in the foreground
|
||||||
|
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
console.log("[ServiceWorker] Installed");
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", () => {
|
||||||
|
console.log("[ServiceWorker] Activated");
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// There's no good way to test this, and Chrome doesn't seem to implement this,
|
||||||
|
// so leaving it for now
|
||||||
|
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||||
|
console.log("[ServiceWorker] PushSubscriptionChange");
|
||||||
|
console.log(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
console.log("[ServiceWorker] Received Web Push Event", { event });
|
||||||
|
// server/types.go webPushPayload
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
const { formatted_title: formattedTitle, subscription_id: subscriptionId, message } = data;
|
||||||
|
broadcastChannel.postMessage(message);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const db = await getDbAsync();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
await db.notifications.add({
|
||||||
|
...message,
|
||||||
|
subscriptionId,
|
||||||
|
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
|
new: 1,
|
||||||
|
});
|
||||||
|
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||||
|
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
|
||||||
|
self.navigator.setAppBadge?.(badgeCount);
|
||||||
|
})(),
|
||||||
|
db.subscriptions.update(subscriptionId, {
|
||||||
|
last: message.id,
|
||||||
|
}),
|
||||||
|
self.registration.showNotification(formattedTitle, {
|
||||||
|
tag: subscriptionId,
|
||||||
|
body: message.message,
|
||||||
|
icon: "/static/images/ntfy.png",
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const { message } = event.notification.data;
|
||||||
|
|
||||||
|
if (message.click) {
|
||||||
|
self.clients.openWindow(message.click);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootUrl = new URL(self.location.origin);
|
||||||
|
const topicUrl = new URL(message.topic, self.location.origin);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
|
||||||
|
const topicClient = clients.find((client) => client.url === topicUrl.toString());
|
||||||
|
if (topicClient) {
|
||||||
|
topicClient.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
||||||
|
if (rootClient) {
|
||||||
|
rootClient.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clients.openWindow(topicUrl);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// self.__WB_MANIFEST is default injection point
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// clean old assets
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// to allow work offline
|
||||||
|
registerRoute(new NavigationRoute(createHandlerBoundToURL("/")));
|
||||||
|
|
||||||
|
registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst());
|
@ -382,6 +382,10 @@ class AccountApi {
|
|||||||
setTimeout(() => this.runWorker(), delayMillis);
|
setTimeout(() => this.runWorker(), delayMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopWorker() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
async runWorker() {
|
async runWorker() {
|
||||||
if (!session.token()) {
|
if (!session.token()) {
|
||||||
return;
|
return;
|
||||||
|
@ -6,6 +6,9 @@ import {
|
|||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince,
|
||||||
|
topicUrlWebPushSubscribe,
|
||||||
|
topicUrlWebPushUnsubscribe,
|
||||||
|
webPushConfigUrl,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
import { fetchOrThrow } from "./errors";
|
import { fetchOrThrow } from "./errors";
|
||||||
@ -113,6 +116,62 @@ class Api {
|
|||||||
}
|
}
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
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();
|
const api = new Api();
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import Connection from "./Connection";
|
import Connection from "./Connection";
|
||||||
|
import { NotificationType } from "./SubscriptionManager";
|
||||||
import { hashCode } from "./utils";
|
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}`);
|
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -45,13 +46,19 @@ class ConnectionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`[ConnectionManager] Refreshing connections`);
|
console.log(`[ConnectionManager] Refreshing connections`);
|
||||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(
|
const subscriptionsWithUsersAndConnectionId = subscriptions
|
||||||
subscriptions.map(async (s) => {
|
.map((s) => {
|
||||||
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
||||||
const connectionId = await makeConnectionId(s, user);
|
const connectionId = makeConnectionId(s, user);
|
||||||
return { ...s, user, connectionId };
|
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 targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
|
||||||
const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
|
const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
|
||||||
|
|
||||||
|
@ -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 prefs from "./Prefs";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
|
||||||
import logo from "../img/ntfy.png";
|
import logo from "../img/ntfy.png";
|
||||||
|
import api from "./Api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The notifier is responsible for displaying desktop notifications. Note that not all modern browsers
|
* 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.
|
* support this; most importantly, all iOS browsers do not support window.Notification.
|
||||||
*/
|
*/
|
||||||
class Notifier {
|
class Notifier {
|
||||||
async notify(subscriptionId, notification, onClickFallback) {
|
async notify(subscription, notification, onClickFallback) {
|
||||||
if (!this.supported()) {
|
if (!this.supported()) {
|
||||||
return;
|
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 shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
const displayName = topicDisplayName(subscription);
|
const displayName = topicDisplayName(subscription);
|
||||||
const message = formatMessage(notification);
|
const message = formatMessage(notification);
|
||||||
@ -26,6 +22,7 @@ class Notifier {
|
|||||||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
||||||
const n = new Notification(title, {
|
const n = new Notification(title, {
|
||||||
body: message,
|
body: message,
|
||||||
|
tag: subscription.id,
|
||||||
icon: logo,
|
icon: logo,
|
||||||
});
|
});
|
||||||
if (notification.click) {
|
if (notification.click) {
|
||||||
@ -33,45 +30,88 @@ class Notifier {
|
|||||||
} else {
|
} else {
|
||||||
n.onclick = () => onClickFallback(subscription);
|
n.onclick = () => onClickFallback(subscription);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async playSound() {
|
||||||
// Play sound
|
// Play sound
|
||||||
const sound = await prefs.sound();
|
const sound = await prefs.sound();
|
||||||
if (sound && sound !== "none") {
|
if (sound && sound !== "none") {
|
||||||
try {
|
try {
|
||||||
await playSound(sound);
|
await playSound(sound);
|
||||||
} catch (e) {
|
} 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() {
|
granted() {
|
||||||
return this.supported() && Notification.permission === "granted";
|
return this.supported() && Notification.permission === "granted";
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeRequestPermission(cb) {
|
denied() {
|
||||||
if (!this.supported()) {
|
return this.supported() && Notification.permission === "denied";
|
||||||
cb(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.granted()) {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
|
||||||
const granted = permission === "granted";
|
|
||||||
cb(granted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async shouldNotify(subscription, notification) {
|
async maybeRequestPermission() {
|
||||||
if (subscription.mutedUntil === 1) {
|
if (!this.supported()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const priority = notification.priority ? notification.priority : 3;
|
|
||||||
const minPriority = await prefs.minPriority();
|
return new Promise((resolve) => {
|
||||||
if (priority < minPriority) {
|
Notification.requestPermission((permission) => {
|
||||||
return false;
|
resolve(permission === "granted");
|
||||||
}
|
});
|
||||||
return true;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
supported() {
|
supported() {
|
||||||
@ -82,6 +122,10 @@ class Notifier {
|
|||||||
return "Notification" in window;
|
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
|
* 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
|
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||||
@ -89,6 +133,10 @@ class Notifier {
|
|||||||
contextSupported() {
|
contextSupported() {
|
||||||
return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
|
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();
|
const notifier = new Notifier();
|
||||||
|
@ -18,6 +18,10 @@ class Poller {
|
|||||||
setTimeout(() => this.pollAll(), delayMillis);
|
setTimeout(() => this.pollAll(), delayMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopWorker() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
async pollAll() {
|
async pollAll() {
|
||||||
console.log(`[Poller] Polling all subscriptions`);
|
console.log(`[Poller] Polling all subscriptions`);
|
||||||
const subscriptions = await subscriptionManager.all();
|
const subscriptions = await subscriptionManager.all();
|
||||||
@ -47,14 +51,13 @@ class Poller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pollInBackground(subscription) {
|
pollInBackground(subscription) {
|
||||||
const fn = async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await this.poll(subscription);
|
await this.poll(subscription);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
setTimeout(() => fn(), 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,33 +1,45 @@
|
|||||||
import db from "./db";
|
import getDb from "./getDb";
|
||||||
|
|
||||||
class Prefs {
|
class Prefs {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
async setSound(sound) {
|
async setSound(sound) {
|
||||||
db.prefs.put({ key: "sound", value: sound.toString() });
|
this.db.prefs.put({ key: "sound", value: sound.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sound() {
|
async sound() {
|
||||||
const sound = await db.prefs.get("sound");
|
const sound = await this.db.prefs.get("sound");
|
||||||
return sound ? sound.value : "ding";
|
return sound ? sound.value : "ding";
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMinPriority(minPriority) {
|
async setMinPriority(minPriority) {
|
||||||
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
this.db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async minPriority() {
|
async minPriority() {
|
||||||
const minPriority = await db.prefs.get("minPriority");
|
const minPriority = await this.db.prefs.get("minPriority");
|
||||||
return minPriority ? Number(minPriority.value) : 1;
|
return minPriority ? Number(minPriority.value) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeleteAfter(deleteAfter) {
|
async setDeleteAfter(deleteAfter) {
|
||||||
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAfter() {
|
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
|
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 new Prefs(getDb());
|
||||||
export default prefs;
|
|
||||||
|
@ -18,6 +18,10 @@ class Pruner {
|
|||||||
setTimeout(() => this.prune(), delayMillis);
|
setTimeout(() => this.prune(), delayMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopWorker() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
async prune() {
|
async prune() {
|
||||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||||
const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
|
import sessionReplica from "./SessionReplica";
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
|
constructor(replica) {
|
||||||
|
this.replica = replica;
|
||||||
|
}
|
||||||
|
|
||||||
store(username, token) {
|
store(username, token) {
|
||||||
localStorage.setItem("user", username);
|
localStorage.setItem("user", username);
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
|
|
||||||
|
this.replica.store(username, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
|
|
||||||
|
this.replica.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAndRedirect(url) {
|
resetAndRedirect(url) {
|
||||||
@ -27,5 +37,5 @@ class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new Session();
|
const session = new Session(sessionReplica);
|
||||||
export default session;
|
export default session;
|
||||||
|
44
web/src/app/SessionReplica.js
Normal file
44
web/src/app/SessionReplica.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import Dexie from "dexie";
|
||||||
|
|
||||||
|
// Store to IndexedDB as well so that the
|
||||||
|
// service worker can access it
|
||||||
|
// TODO: Probably make everything depend on this and not use localStorage,
|
||||||
|
// but that's a larger refactoring effort for another PR
|
||||||
|
|
||||||
|
class SessionReplica {
|
||||||
|
constructor() {
|
||||||
|
const db = new Dexie("session-replica");
|
||||||
|
|
||||||
|
db.version(1).stores({
|
||||||
|
keyValueStore: "&key",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(username, token) {
|
||||||
|
try {
|
||||||
|
await this.db.keyValueStore.bulkPut([
|
||||||
|
{ key: "user", value: username },
|
||||||
|
{ key: "token", value: token },
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Session] Error replicating session to IndexedDB", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
try {
|
||||||
|
await this.db.delete();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Session] Error resetting session on IndexedDB", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async username() {
|
||||||
|
return (await this.db.keyValueStore.get({ key: "user" }))?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionReplica = new SessionReplica();
|
||||||
|
export default sessionReplica;
|
@ -1,47 +1,112 @@
|
|||||||
import db from "./db";
|
import notifier from "./Notifier";
|
||||||
|
import prefs from "./Prefs";
|
||||||
|
import getDb from "./getDb";
|
||||||
import { topicUrl } from "./utils";
|
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 {
|
class SubscriptionManager {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||||
async all() {
|
async all() {
|
||||||
const subscriptions = await db.subscriptions.toArray();
|
const subscriptions = await this.db.subscriptions.toArray();
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
subscriptions.map(async (s) => ({
|
subscriptions.map(async (s) => ({
|
||||||
...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) {
|
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 id = topicUrl(baseUrl, topic);
|
||||||
|
|
||||||
|
const webPushFields = opts.notificationType === "background" ? await notifier.subscribeWebPush(baseUrl, topic) : {};
|
||||||
|
|
||||||
const existingSubscription = await this.get(id);
|
const existingSubscription = await this.get(id);
|
||||||
if (existingSubscription) {
|
if (existingSubscription) {
|
||||||
|
if (webPushFields.endpoint) {
|
||||||
|
await this.db.subscriptions.update(existingSubscription.id, {
|
||||||
|
webPushEndpoint: webPushFields.endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return existingSubscription;
|
return existingSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = {
|
const subscription = {
|
||||||
id: topicUrl(baseUrl, topic),
|
id: topicUrl(baseUrl, topic),
|
||||||
baseUrl,
|
baseUrl,
|
||||||
topic,
|
topic,
|
||||||
mutedUntil: 0,
|
mutedUntil: 0,
|
||||||
last: null,
|
last: null,
|
||||||
internal: internal || false,
|
...opts,
|
||||||
|
webPushEndpoint: webPushFields.endpoint,
|
||||||
};
|
};
|
||||||
await db.subscriptions.put(subscription);
|
|
||||||
|
await this.db.subscriptions.put(subscription);
|
||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||||
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||||
|
|
||||||
|
const notificationType = (await prefs.webPushDefaultEnabled()) === "enabled" ? "background" : "browser";
|
||||||
|
|
||||||
// Add remote subscriptions
|
// Add remote subscriptions
|
||||||
const remoteIds = await Promise.all(
|
const remoteIds = await Promise.all(
|
||||||
remoteSubscriptions.map(async (remote) => {
|
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;
|
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||||
|
|
||||||
await this.update(local.id, {
|
await this.update(local.id, {
|
||||||
@ -54,29 +119,33 @@ class SubscriptionManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Remove local subscriptions that do not exist remotely
|
// Remove local subscriptions that do not exist remotely
|
||||||
const localSubscriptions = await db.subscriptions.toArray();
|
const localSubscriptions = await this.db.subscriptions.toArray();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
localSubscriptions.map(async (local) => {
|
localSubscriptions.map(async (local) => {
|
||||||
const remoteExists = remoteIds.includes(local.id);
|
const remoteExists = remoteIds.includes(local.id);
|
||||||
if (!local.internal && !remoteExists) {
|
if (!local.internal && !remoteExists) {
|
||||||
await this.remove(local.id);
|
await this.remove(local);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateState(subscriptionId, state) {
|
async updateState(subscriptionId, state) {
|
||||||
db.subscriptions.update(subscriptionId, { state });
|
this.db.subscriptions.update(subscriptionId, { state });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(subscriptionId) {
|
async remove(subscription) {
|
||||||
await db.subscriptions.delete(subscriptionId);
|
await this.db.subscriptions.delete(subscription.id);
|
||||||
await db.notifications.where({ subscriptionId }).delete();
|
await this.db.notifications.where({ subscriptionId: subscription.id }).delete();
|
||||||
|
|
||||||
|
if (subscription.webPushEndpoint) {
|
||||||
|
await notifier.unsubscribeWebPush(subscription);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async first() {
|
async first() {
|
||||||
return db.subscriptions.toCollection().first(); // May be undefined
|
return this.db.subscriptions.toCollection().first(); // May be undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotifications(subscriptionId) {
|
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
|
// 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
|
// 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
|
.orderBy("time") // Sort by time first
|
||||||
.filter((n) => n.subscriptionId === subscriptionId)
|
.filter((n) => n.subscriptionId === subscriptionId)
|
||||||
.reverse()
|
.reverse()
|
||||||
@ -92,7 +161,7 @@ class SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAllNotifications() {
|
async getAllNotifications() {
|
||||||
return db.notifications
|
return this.db.notifications
|
||||||
.orderBy("time") // Efficient, see docs
|
.orderBy("time") // Efficient, see docs
|
||||||
.reverse()
|
.reverse()
|
||||||
.toArray();
|
.toArray();
|
||||||
@ -100,18 +169,19 @@ class SubscriptionManager {
|
|||||||
|
|
||||||
/** Adds notification, or returns false if it already exists */
|
/** Adds notification, or returns false if it already exists */
|
||||||
async addNotification(subscriptionId, notification) {
|
async addNotification(subscriptionId, notification) {
|
||||||
const exists = await db.notifications.get(notification.id);
|
const exists = await this.db.notifications.get(notification.id);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
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,
|
...notification,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
new: 1,
|
new: 1,
|
||||||
}); // FIXME consider put() for double tab
|
}); // FIXME consider put() for double tab
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
last: notification.id,
|
last: notification.id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -124,19 +194,19 @@ class SubscriptionManager {
|
|||||||
async addNotifications(subscriptionId, notifications) {
|
async addNotifications(subscriptionId, notifications) {
|
||||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
||||||
const lastNotificationId = notifications.at(-1).id;
|
const lastNotificationId = notifications.at(-1).id;
|
||||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
last: lastNotificationId,
|
last: lastNotificationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateNotification(notification) {
|
async updateNotification(notification) {
|
||||||
const exists = await db.notifications.get(notification.id);
|
const exists = await this.db.notifications.get(notification.id);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await db.notifications.put({ ...notification });
|
await this.db.notifications.put({ ...notification });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||||
}
|
}
|
||||||
@ -144,47 +214,105 @@ class SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotification(notificationId) {
|
async deleteNotification(notificationId) {
|
||||||
await db.notifications.delete(notificationId);
|
await this.db.notifications.delete(notificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotifications(subscriptionId) {
|
async deleteNotifications(subscriptionId) {
|
||||||
await db.notifications.where({ subscriptionId }).delete();
|
await this.db.notifications.where({ subscriptionId }).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async markNotificationRead(notificationId) {
|
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) {
|
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) {
|
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
mutedUntil,
|
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) {
|
async setDisplayName(subscriptionId, displayName) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
displayName,
|
displayName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setReservation(subscriptionId, reservation) {
|
async setReservation(subscriptionId, reservation) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
await this.db.subscriptions.update(subscriptionId, {
|
||||||
reservation,
|
reservation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(subscriptionId, params) {
|
async update(subscriptionId, params) {
|
||||||
await db.subscriptions.update(subscriptionId, params);
|
await this.db.subscriptions.update(subscriptionId, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pruneNotifications(thresholdTimestamp) {
|
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 new SubscriptionManager(getDb());
|
||||||
export default subscriptionManager;
|
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import db from "./db";
|
import getDb from "./getDb";
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
|
|
||||||
class UserManager {
|
class UserManager {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
async all() {
|
async all() {
|
||||||
const users = await db.users.toArray();
|
const users = await this.db.users.toArray();
|
||||||
if (session.exists()) {
|
if (session.exists()) {
|
||||||
users.unshift(this.localUser());
|
users.unshift(this.localUser());
|
||||||
}
|
}
|
||||||
@ -14,21 +18,21 @@ class UserManager {
|
|||||||
if (session.exists() && baseUrl === config.base_url) {
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
return this.localUser();
|
return this.localUser();
|
||||||
}
|
}
|
||||||
return db.users.get(baseUrl);
|
return this.db.users.get(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(user) {
|
async save(user) {
|
||||||
if (session.exists() && user.baseUrl === config.base_url) {
|
if (session.exists() && user.baseUrl === config.base_url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await db.users.put(user);
|
await this.db.users.put(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(baseUrl) {
|
async delete(baseUrl) {
|
||||||
if (session.exists() && baseUrl === config.base_url) {
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await db.users.delete(baseUrl);
|
await this.db.users.delete(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
localUser() {
|
localUser() {
|
||||||
@ -43,5 +47,4 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userManager = new UserManager();
|
export default new UserManager(getDb());
|
||||||
export default userManager;
|
|
||||||
|
46
web/src/app/WebPushWorker.js
Normal file
46
web/src/app/WebPushWorker.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import notifier from "./Notifier";
|
||||||
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
|
||||||
|
const onMessage = () => {
|
||||||
|
notifier.playSound();
|
||||||
|
};
|
||||||
|
|
||||||
|
const delayMillis = 2000; // 2 seconds
|
||||||
|
const intervalMillis = 300000; // 5 minutes
|
||||||
|
|
||||||
|
class WebPushWorker {
|
||||||
|
constructor() {
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorker() {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis);
|
||||||
|
setTimeout(() => this.updateSubscriptions(), delayMillis);
|
||||||
|
|
||||||
|
this.broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||||
|
this.broadcastChannel.addEventListener("message", onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWorker() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
|
||||||
|
this.broadcastChannel.removeEventListener("message", onMessage);
|
||||||
|
this.broadcastChannel.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscriptions() {
|
||||||
|
try {
|
||||||
|
console.log("[WebPushBroadcastListener] Refreshing web push subscriptions");
|
||||||
|
|
||||||
|
await subscriptionManager.refreshWebPushSubscriptions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[WebPushBroadcastListener] Error refreshing web push subscriptions", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WebPushWorker();
|
@ -1,21 +0,0 @@
|
|||||||
import Dexie from "dexie";
|
|
||||||
import session from "./Session";
|
|
||||||
|
|
||||||
// Uses Dexie.js
|
|
||||||
// https://dexie.org/docs/API-Reference#quick-reference
|
|
||||||
//
|
|
||||||
// Notes:
|
|
||||||
// - As per docs, we only declare the indexable columns, not all columns
|
|
||||||
|
|
||||||
// The IndexedDB database name is based on the logged-in user
|
|
||||||
const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
|
|
||||||
const db = new Dexie(dbName);
|
|
||||||
|
|
||||||
db.version(1).stores({
|
|
||||||
subscriptions: "&id,baseUrl",
|
|
||||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
|
||||||
users: "&baseUrl,username",
|
|
||||||
prefs: "&key",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default db;
|
|
34
web/src/app/getDb.js
Normal file
34
web/src/app/getDb.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Dexie from "dexie";
|
||||||
|
import session from "./Session";
|
||||||
|
import sessionReplica from "./SessionReplica";
|
||||||
|
|
||||||
|
// Uses Dexie.js
|
||||||
|
// https://dexie.org/docs/API-Reference#quick-reference
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - As per docs, we only declare the indexable columns, not all columns
|
||||||
|
|
||||||
|
const getDbBase = (username) => {
|
||||||
|
// The IndexedDB database name is based on the logged-in user
|
||||||
|
const dbName = username ? `ntfy-${username}` : "ntfy";
|
||||||
|
const db = new Dexie(dbName);
|
||||||
|
|
||||||
|
db.version(2).stores({
|
||||||
|
subscriptions: "&id,baseUrl,notificationType",
|
||||||
|
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||||
|
users: "&baseUrl,username",
|
||||||
|
prefs: "&key",
|
||||||
|
});
|
||||||
|
|
||||||
|
return db;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDbAsync = async () => {
|
||||||
|
const username = await sessionReplica.username();
|
||||||
|
|
||||||
|
return getDbBase(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDb = () => getDbBase(session.username());
|
||||||
|
|
||||||
|
export default getDb;
|
@ -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 topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
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 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 accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
||||||
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
||||||
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
||||||
@ -156,7 +159,7 @@ export const splitNoEmpty = (s, delimiter) =>
|
|||||||
.filter((x) => x !== "");
|
.filter((x) => x !== "");
|
||||||
|
|
||||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||||
export const hashCode = async (s) => {
|
export const hashCode = (s) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < s.length; i += 1) {
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
const char = s.charCodeAt(i);
|
const char = s.charCodeAt(i);
|
||||||
@ -288,3 +291,16 @@ export const randomAlphanumericString = (len) => {
|
|||||||
}
|
}
|
||||||
return id;
|
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;
|
||||||
|
};
|
||||||
|
@ -48,7 +48,7 @@ import routes from "./routes";
|
|||||||
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
|
||||||
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
|
||||||
import { Pref, PrefGroup } from "./Pref";
|
import { Pref, PrefGroup } from "./Pref";
|
||||||
import db from "../app/db";
|
import getDb from "../app/getDb";
|
||||||
import UpgradeDialog from "./UpgradeDialog";
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
@ -57,6 +57,7 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
|||||||
import { ProChip } from "./SubscriptionPopup";
|
import { ProChip } from "./SubscriptionPopup";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
@ -1077,8 +1078,10 @@ const DeleteAccountDialog = (props) => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
await subscriptionManager.unsubscribeAllWebPush();
|
||||||
|
|
||||||
await accountApi.delete(password);
|
await accountApi.delete(password);
|
||||||
await db.delete();
|
await getDb().delete();
|
||||||
console.debug(`[Account] Account deleted`);
|
console.debug(`[Account] Account deleted`);
|
||||||
session.resetAndRedirect(routes.app);
|
session.resetAndRedirect(routes.app);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -13,7 +13,7 @@ import session from "../app/Session";
|
|||||||
import logo from "../img/ntfy.svg";
|
import logo from "../img/ntfy.svg";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import db from "../app/db";
|
import getDb from "../app/getDb";
|
||||||
import { topicDisplayName } from "../app/utils";
|
import { topicDisplayName } from "../app/utils";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
@ -120,8 +120,10 @@ const ProfileIcon = () => {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
|
await subscriptionManager.unsubscribeAllWebPush();
|
||||||
|
|
||||||
await accountApi.logout();
|
await accountApi.logout();
|
||||||
await db.delete();
|
await getDb().delete();
|
||||||
} finally {
|
} finally {
|
||||||
session.resetAndRedirect(routes.app);
|
session.resetAndRedirect(routes.app);
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,10 @@ const App = () => {
|
|||||||
|
|
||||||
const updateTitle = (newNotificationsCount) => {
|
const updateTitle = (newNotificationsCount) => {
|
||||||
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||||
|
|
||||||
|
if ("setAppBadge" in window.navigator) {
|
||||||
|
window.navigator.setAppBadge(newNotificationsCount);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
@ -14,7 +14,6 @@ import {
|
|||||||
ListSubheader,
|
ListSubheader,
|
||||||
Portal,
|
Portal,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
@ -94,15 +93,10 @@ const NavList = (props) => {
|
|||||||
setSubscribeDialogKey((prev) => prev + 1);
|
setSubscribeDialogKey((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRequestNotificationPermission = () => {
|
|
||||||
notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubscribeSubmit = (subscription) => {
|
const handleSubscribeSubmit = (subscription) => {
|
||||||
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
|
console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
|
||||||
handleSubscribeReset();
|
handleSubscribeReset();
|
||||||
navigate(routes.forSubscription(subscription));
|
navigate(routes.forSubscription(subscription));
|
||||||
handleRequestNotificationPermission();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAccountClick = () => {
|
const handleAccountClick = () => {
|
||||||
@ -114,19 +108,27 @@ const NavList = (props) => {
|
|||||||
const isPaid = account?.billing?.subscription;
|
const isPaid = account?.billing?.subscription;
|
||||||
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
||||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
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 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 =
|
const navListPadding =
|
||||||
showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : "";
|
showNotificationPermissionDenied ||
|
||||||
|
showNotificationIOSInstallRequired ||
|
||||||
|
showNotificationBrowserNotSupportedBox ||
|
||||||
|
showNotificationContextNotSupportedBox
|
||||||
|
? "0"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
||||||
<List component="nav" sx={{ paddingTop: navListPadding }}>
|
<List component="nav" sx={{ paddingTop: navListPadding }}>
|
||||||
|
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
||||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
||||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
|
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
|
||||||
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission} />}
|
{showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}
|
||||||
{!showSubscriptionsList && (
|
{!showSubscriptionsList && (
|
||||||
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
|
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
@ -344,16 +346,26 @@ const SubscriptionItem = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationGrantAlert = (props) => {
|
const NotificationPermissionDeniedAlert = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
||||||
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
|
<AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
|
||||||
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
|
<Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
|
||||||
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={props.onRequestPermissionClick}>
|
</Alert>
|
||||||
{t("alert_grant_button")}
|
<Divider />
|
||||||
</Button>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationIOSInstallRequiredAlert = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert severity="warning" sx={{ paddingTop: 2 }}>
|
||||||
|
<AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle>
|
||||||
|
<Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
|
@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite
|
|||||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import { subscribeTopic } from "./SubscribeDialog";
|
import { subscribeTopic } from "./SubscribeDialog";
|
||||||
|
import notifier from "../app/Notifier";
|
||||||
|
|
||||||
const maybeUpdateAccountSettings = async (payload) => {
|
const maybeUpdateAccountSettings = async (payload) => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
@ -85,6 +86,7 @@ const Notifications = () => {
|
|||||||
<Sound />
|
<Sound />
|
||||||
<MinPriority />
|
<MinPriority />
|
||||||
<DeleteAfter />
|
<DeleteAfter />
|
||||||
|
{notifier.pushSupported() && <WebPushDefaultEnabled />}
|
||||||
</PrefGroup>
|
</PrefGroup>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@ -232,6 +234,36 @@ const DeleteAfter = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WebPushDefaultEnabled = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labelId = "prefWebPushDefaultEnabled";
|
||||||
|
const defaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled());
|
||||||
|
const handleChange = async (ev) => {
|
||||||
|
await prefs.setWebPushDefaultEnabled(ev.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// while loading
|
||||||
|
if (defaultEnabled == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref
|
||||||
|
labelId={labelId}
|
||||||
|
title={t("prefs_notifications_web_push_default_title")}
|
||||||
|
description={t("prefs_notifications_web_push_default_description")}
|
||||||
|
>
|
||||||
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
|
<Select value={defaultEnabled} onChange={handleChange} aria-labelledby={labelId}>
|
||||||
|
{defaultEnabled === "initial" && <MenuItem value="initial">{t("prefs_notifications_web_push_default_initial")}</MenuItem>}
|
||||||
|
<MenuItem value="enabled">{t("prefs_notifications_web_push_default_enabled")}</MenuItem>
|
||||||
|
<MenuItem value="disabled">{t("prefs_notifications_web_push_default_disabled")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Pref>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Users = () => {
|
const Users = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
@ -8,17 +8,20 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Checkbox,
|
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
|
Switch,
|
||||||
|
Stack,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Warning } from "@mui/icons-material";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
|
import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
@ -28,11 +31,13 @@ import ReserveTopicSelect from "./ReserveTopicSelect";
|
|||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { TopicReservedError, UnauthorizedError } from "../app/errors";
|
import { TopicReservedError, UnauthorizedError } from "../app/errors";
|
||||||
import { ReserveLimitChip } from "./SubscriptionPopup";
|
import { ReserveLimitChip } from "./SubscriptionPopup";
|
||||||
|
import notifier from "../app/Notifier";
|
||||||
|
import prefs from "../app/Prefs";
|
||||||
|
|
||||||
const publicBaseUrl = "https://ntfy.sh";
|
const publicBaseUrl = "https://ntfy.sh";
|
||||||
|
|
||||||
export const subscribeTopic = async (baseUrl, topic) => {
|
export const subscribeTopic = async (baseUrl, topic, opts) => {
|
||||||
const subscription = await subscriptionManager.add(baseUrl, topic);
|
const subscription = await subscriptionManager.add(baseUrl, topic, opts);
|
||||||
if (session.exists()) {
|
if (session.exists()) {
|
||||||
try {
|
try {
|
||||||
await accountApi.addSubscription(baseUrl, topic);
|
await accountApi.addSubscription(baseUrl, topic);
|
||||||
@ -52,14 +57,29 @@ const SubscribeDialog = (props) => {
|
|||||||
const [showLoginPage, setShowLoginPage] = useState(false);
|
const [showLoginPage, setShowLoginPage] = useState(false);
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
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}`);
|
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
|
||||||
const actualBaseUrl = baseUrl || config.base_url;
|
const actualBaseUrl = baseUrl || config.base_url;
|
||||||
const subscription = await subscribeTopic(actualBaseUrl, topic);
|
const subscription = await subscribeTopic(actualBaseUrl, topic, {
|
||||||
|
notificationType,
|
||||||
|
});
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
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);
|
props.onSuccess(subscription);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// wait for liveQuery load
|
||||||
|
if (webPushDefaultEnabled === undefined) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
{!showLoginPage && (
|
{!showLoginPage && (
|
||||||
@ -72,6 +92,7 @@ const SubscribeDialog = (props) => {
|
|||||||
onCancel={props.onCancel}
|
onCancel={props.onCancel}
|
||||||
onNeedsLogin={() => setShowLoginPage(true)}
|
onNeedsLogin={() => setShowLoginPage(true)}
|
||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
|
webPushDefaultEnabled={webPushDefaultEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
|
{showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
|
||||||
@ -79,6 +100,22 @@ const SubscribeDialog = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const browserNotificationsSupported = notifier.supported();
|
||||||
|
const pushNotificationsSupported = notifier.pushSupported();
|
||||||
|
const iosInstallRequired = notifier.iosSupportedButInstallRequired();
|
||||||
|
|
||||||
|
const getNotificationTypeFromToggles = (browserNotificationsEnabled, backgroundNotificationsEnabled) => {
|
||||||
|
if (backgroundNotificationsEnabled) {
|
||||||
|
return NotificationType.BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browserNotificationsEnabled) {
|
||||||
|
return NotificationType.BROWSER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationType.SOUND;
|
||||||
|
};
|
||||||
|
|
||||||
const SubscribePage = (props) => {
|
const SubscribePage = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
@ -96,6 +133,30 @@ const SubscribePage = (props) => {
|
|||||||
const reserveTopicEnabled =
|
const reserveTopicEnabled =
|
||||||
session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
|
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 handleSubscribe = async () => {
|
||||||
const user = await userManager.get(baseUrl); // May be undefined
|
const user = await userManager.get(baseUrl); // May be undefined
|
||||||
const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
|
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}`);
|
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
||||||
props.onSuccess();
|
props.onSuccess(getNotificationTypeFromToggles(browserNotificationsEnabled, backgroundNotificationsEnabled));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUseAnotherChanged = (e) => {
|
const handleUseAnotherChanged = (e) => {
|
||||||
props.setBaseUrl("");
|
props.setBaseUrl("");
|
||||||
setAnotherServerVisible(e.target.checked);
|
setAnotherServerVisible(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
setBackgroundNotificationsEnabled(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscribeButtonEnabled = (() => {
|
const subscribeButtonEnabled = (() => {
|
||||||
@ -193,8 +257,7 @@ const SubscribePage = (props) => {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
variant="standard"
|
variant="standard"
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Switch
|
||||||
fullWidth
|
|
||||||
disabled={!reserveTopicEnabled}
|
disabled={!reserveTopicEnabled}
|
||||||
checked={reserveTopicVisible}
|
checked={reserveTopicVisible}
|
||||||
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||||
@ -217,8 +280,9 @@ const SubscribePage = (props) => {
|
|||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Switch
|
||||||
onChange={handleUseAnotherChanged}
|
onChange={handleUseAnotherChanged}
|
||||||
|
checked={anotherServerVisible}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
|
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
|
||||||
}}
|
}}
|
||||||
@ -244,6 +308,43 @@ const SubscribePage = (props) => {
|
|||||||
)}
|
)}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
|
{browserNotificationsSupported && (
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
onChange={handleBrowserNotificationsChanged}
|
||||||
|
checked={browserNotificationsEnabled}
|
||||||
|
disabled={notificationsExplicitlyDenied}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("subscribe_dialog_subscribe_enable_browser_notifications_label"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Stack direction="row" gap={1} alignItems="center">
|
||||||
|
{t("subscribe_dialog_subscribe_enable_browser_notifications_label")}
|
||||||
|
{notificationsExplicitlyDenied && <Warning />}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{pushNotificationsSupported && !anotherServerVisible && browserNotificationsEnabled && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
onChange={handleBackgroundNotificationsChanged}
|
||||||
|
checked={backgroundNotificationsEnabled}
|
||||||
|
disabled={iosInstallRequired}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={error}>
|
<DialogFooter status={error}>
|
||||||
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
||||||
|
@ -14,12 +14,26 @@ import {
|
|||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 theme from "./theme";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import accountApi, { Role } from "../app/AccountApi";
|
import accountApi, { Role } from "../app/AccountApi";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
@ -30,6 +44,7 @@ import api from "../app/Api";
|
|||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
|
import notifier from "../app/Notifier";
|
||||||
|
|
||||||
export const SubscriptionPopup = (props) => {
|
export const SubscriptionPopup = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -70,8 +85,7 @@ export const SubscriptionPopup = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendTestMessage = async () => {
|
const handleSendTestMessage = async () => {
|
||||||
const { baseUrl } = props.subscription;
|
const { baseUrl, topic } = props.subscription;
|
||||||
const { topic } = props.subscription;
|
|
||||||
const tags = shuffle([
|
const tags = shuffle([
|
||||||
"grinning",
|
"grinning",
|
||||||
"octopus",
|
"octopus",
|
||||||
@ -133,7 +147,7 @@ export const SubscriptionPopup = (props) => {
|
|||||||
|
|
||||||
const handleUnsubscribe = async () => {
|
const handleUnsubscribe = async () => {
|
||||||
console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
|
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) {
|
if (session.exists() && !subscription.internal) {
|
||||||
try {
|
try {
|
||||||
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
|
await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic);
|
||||||
@ -155,19 +169,72 @@ export const SubscriptionPopup = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
|
<PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
|
||||||
<MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem>
|
<NotificationToggle subscription={subscription} />
|
||||||
{showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>}
|
<Divider />
|
||||||
|
<MenuItem onClick={handleChangeDisplayName}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_change_display_name")}
|
||||||
|
</MenuItem>
|
||||||
|
{showReservationAdd && (
|
||||||
|
<MenuItem onClick={handleReserveAdd}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Lock fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{t("action_bar_reservation_add")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{showReservationAddDisabled && (
|
{showReservationAddDisabled && (
|
||||||
<MenuItem sx={{ cursor: "default" }}>
|
<MenuItem sx={{ cursor: "default" }}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Lock fontSize="small" color="disabled" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
<span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
|
<span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span>
|
||||||
<ReserveLimitChip />
|
<ReserveLimitChip />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>}
|
{showReservationEdit && (
|
||||||
{showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>}
|
<MenuItem onClick={handleReserveEdit}>
|
||||||
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
<ListItemIcon>
|
||||||
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
<EnhancedEncryption fontSize="small" />
|
||||||
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_reservation_edit")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{showReservationDelete && (
|
||||||
|
<MenuItem onClick={handleReserveDelete}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LockOpen fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_reservation_delete")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={handleSendTestMessage}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Send fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_send_test_notification")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleClearAll}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ClearAll fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_clear_notifications")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleUnsubscribe}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<RemoveCircle fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{t("action_bar_unsubscribe")}
|
||||||
|
</MenuItem>
|
||||||
</PopupMenu>
|
</PopupMenu>
|
||||||
<Portal>
|
<Portal>
|
||||||
<Snackbar
|
<Snackbar
|
||||||
@ -267,6 +334,83 @@ const DisplayNameDialog = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNotificationType = (subscription) => {
|
||||||
|
if (subscription.mutedUntil === 1) {
|
||||||
|
return "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription.notificationType ?? NotificationType.BROWSER;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkedItem = (
|
||||||
|
<ListItemIcon>
|
||||||
|
<Check />
|
||||||
|
</ListItemIcon>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NotificationToggle = ({ subscription }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const type = getNotificationType(subscription);
|
||||||
|
|
||||||
|
const handleChange = async (newType) => {
|
||||||
|
try {
|
||||||
|
if (newType !== NotificationType.SOUND && !(await notifier.maybeRequestPermission())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionManager.setNotificationType(subscription, newType);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[NotificationToggle] Error setting notification type", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmute = async () => {
|
||||||
|
await subscriptionManager.setMutedUntil(subscription.id, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "muted") {
|
||||||
|
return (
|
||||||
|
<MenuItem onClick={unmute}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<NotificationsOff />
|
||||||
|
</ListItemIcon>
|
||||||
|
{t("notification_toggle_unmute")}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem>
|
||||||
|
{type === NotificationType.SOUND && checkedItem}
|
||||||
|
<ListItemText inset={type !== NotificationType.SOUND} onClick={() => handleChange(NotificationType.SOUND)}>
|
||||||
|
{t("notification_toggle_sound")}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
{!notifier.denied() && !notifier.iosSupportedButInstallRequired() && (
|
||||||
|
<>
|
||||||
|
{notifier.supported() && (
|
||||||
|
<MenuItem>
|
||||||
|
{type === NotificationType.BROWSER && checkedItem}
|
||||||
|
<ListItemText inset={type !== NotificationType.BROWSER} onClick={() => handleChange(NotificationType.BROWSER)}>
|
||||||
|
{t("notification_toggle_browser")}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{notifier.pushSupported() && (
|
||||||
|
<MenuItem>
|
||||||
|
{type === NotificationType.BACKGROUND && checkedItem}
|
||||||
|
<ListItemText inset={type !== NotificationType.BACKGROUND} onClick={() => handleChange(NotificationType.BACKGROUND)}>
|
||||||
|
{t("notification_toggle_background")}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ReserveLimitChip = () => {
|
export const ReserveLimitChip = () => {
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
|
if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) {
|
||||||
|
@ -2,7 +2,6 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
|
import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
|
||||||
import notifier from "../app/Notifier";
|
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import connectionManager from "../app/ConnectionManager";
|
import connectionManager from "../app/ConnectionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
@ -10,6 +9,7 @@ import pruner from "../app/Pruner";
|
|||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
|
import webPushWorker from "../app/WebPushWorker";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
* 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);
|
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
||||||
if (added) {
|
if (added) {
|
||||||
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription));
|
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);
|
connectionManager.registerMessageListener(handleMessage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -79,7 +79,7 @@ export const useConnectionListeners = (account, subscriptions, users) => {
|
|||||||
if (!account || !account.sync_topic) {
|
if (!account || !account.sync_topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
|
subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!
|
||||||
}, [account]);
|
}, [account]);
|
||||||
|
|
||||||
// When subscriptions or users change, refresh the connections
|
// 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
|
* 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.
|
* 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 = () => {
|
export const useBackgroundProcesses = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poller.startWorker();
|
console.log("[useBackgroundProcesses] mounting");
|
||||||
pruner.startWorker();
|
startWorkers();
|
||||||
accountApi.startWorker();
|
webPushWorker.startWorker();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("[useBackgroundProcesses] unloading");
|
||||||
|
stopWorkers();
|
||||||
|
webPushWorker.stopWorker();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,73 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
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(() => ({
|
export default defineConfig(() => ({
|
||||||
build: {
|
build: {
|
||||||
outDir: "build",
|
outDir: "build",
|
||||||
assetsDir: "static/media",
|
assetsDir: "static/media",
|
||||||
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
Loading…
Reference in New Issue
Block a user