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
					
				
					 53 changed files with 4363 additions and 249 deletions
				
			
		
							
								
								
									
										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} | ||||||
|  |  | ||||||
							
								
								
									
										206
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										206
									
								
								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,6 +25,16 @@ 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 | ||||||
|  | @ -52,6 +55,7 @@ type Server struct { | ||||||
| 	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 | ||||||
|  | 	webPushSubscriptionStore *webPushSubscriptionStore           // Database that stores web push subscriptions | ||||||
| 	fileCache                *fileCache                          // File system based cache that stores attachments | 	fileCache                *fileCache                          // File system based cache that stores attachments | ||||||
| 	stripe                   stripeAPI                           // Stripe API, can be replaced with a mock | 	stripe                   stripeAPI                           // Stripe API, can be replaced with a mock | ||||||
| 	priceCache               *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) | 	priceCache               *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) | ||||||
|  | @ -73,9 +77,13 @@ var ( | ||||||
| 	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$`) | ||||||
|  | 	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)$`) | 	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 | ||||||
|  | @ -190,6 +203,7 @@ func New(conf *Config) (*Server, error) { | ||||||
| 	s := &Server{ | 	s := &Server{ | ||||||
| 		config:                   conf, | 		config:                   conf, | ||||||
| 		messageCache:             messageCache, | 		messageCache:             messageCache, | ||||||
|  | 		webPushSubscriptionStore: webPushSubscriptionStore, | ||||||
| 		fileCache:                fileCache, | 		fileCache:                fileCache, | ||||||
| 		firebaseClient:           firebaseClient, | 		firebaseClient:           firebaseClient, | ||||||
| 		smtpSender:               mailer, | 		smtpSender:               mailer, | ||||||
|  | @ -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.
 | ||||||
|  */ |  */ | ||||||
| export const useBackgroundProcesses = () => { | 
 | ||||||
|   useEffect(() => { | const stopWorkers = () => { | ||||||
|  |   poller.stopWorker(); | ||||||
|  |   pruner.stopWorker(); | ||||||
|  |   accountApi.stopWorker(); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const startWorkers = () => { | ||||||
|   poller.startWorker(); |   poller.startWorker(); | ||||||
|   pruner.startWorker(); |   pruner.startWorker(); | ||||||
|   accountApi.startWorker(); |   accountApi.startWorker(); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useBackgroundProcesses = () => { | ||||||
|  |   useEffect(() => { | ||||||
|  |     console.log("[useBackgroundProcesses] mounting"); | ||||||
|  |     startWorkers(); | ||||||
|  |     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…
	
	Add table
		Add a link
		
	
		Reference in a new issue