diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 0076c0fa..de22292a 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
- go-version: '1.19.x'
+ go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index f709332a..b61e3361 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -14,7 +14,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
- go-version: '1.19.x'
+ go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 7473567b..f76862a9 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
- go-version: '1.19.x'
+ go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3
diff --git a/.gitignore b/.gitignore
index b0c2d330..b60c9b23 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
dist/
+dev-dist/
build/
.idea/
.vscode/
@@ -12,3 +13,4 @@ secrets/
node_modules/
.DS_Store
__pycache__
+web/dev-dist/
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 131a302a..d3e71df2 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -119,8 +119,6 @@ archives:
- server/ntfy.service
- client/client.yml
- client/ntfy-client.service
- replacements:
- amd64: x86_64
-
id: ntfy_windows
builds:
@@ -131,8 +129,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
- replacements:
- amd64: x86_64
-
id: ntfy_darwin
builds:
@@ -142,8 +138,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
- replacements:
- darwin: macOS
universal_binaries:
-
id: ntfy_darwin_all
diff --git a/README.md b/README.md
index cebf55be..4c4f6645 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)
-[![Reddit](https://img.shields.io/reddit/subreddit-subscribers/ntfy?color=%23317f6f&label=-%20r%2Fntfy&style=social)](https://www.reddit.com/r/ntfy/)
+[![Lemmy](https://img.shields.io/badge/Lemmy-discuss-green)](https://discuss.ntfy.sh/c/ntfy)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
@@ -47,9 +47,8 @@ works best for you:
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
-* [Reddit r/ntfy](https://www.reddit.com/r/ntfy/) - asynchronous forum (_new as of October 2022_)
+* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
-* [Email](https://heckel.io/about) - reach me directly (_I usually prefer the other methods_)
## Announcements / beta testers
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
@@ -141,6 +140,7 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
+
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
@@ -178,3 +178,4 @@ Third party libraries and resources:
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
+* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
diff --git a/client/client.go b/client/client.go
index b744fa11..93cf7da5 100644
--- a/client/client.go
+++ b/client/client.go
@@ -11,23 +11,25 @@ import (
"heckel.io/ntfy/util"
"io"
"net/http"
+ "regexp"
"strings"
"sync"
"time"
)
-// Event type constants
const (
- MessageEvent = "message"
- KeepaliveEvent = "keepalive"
- OpenEvent = "open"
- PollRequestEvent = "poll_request"
+ // MessageEvent identifies a message event
+ MessageEvent = "message"
)
const (
maxResponseBytes = 4096
)
+var (
+ topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go
+)
+
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
type Client struct {
Messages chan *Message
@@ -96,8 +98,14 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
// WithNoFirebase, and the generic WithHeader.
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
- topicURL := c.expandTopicURL(topic)
- req, _ := http.NewRequest("POST", topicURL, body)
+ topicURL, err := c.expandTopicURL(topic)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequest("POST", topicURL, body)
+ if err != nil {
+ return nil, err
+ }
for _, option := range options {
if err := option(req); err != nil {
return nil, err
@@ -133,11 +141,14 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
+ topicURL, err := c.expandTopicURL(topic)
+ if err != nil {
+ return nil, err
+ }
ctx := context.Background()
messages := make([]*Message, 0)
msgChan := make(chan *Message)
errChan := make(chan error)
- topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll())
go func() {
@@ -166,15 +177,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// Example:
//
// c := client.New(client.NewConfig())
-// subscriptionID := c.Subscribe("mytopic")
+// subscriptionID, _ := c.Subscribe("mytopic")
// for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message)
// }
-func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
+func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {
+ topicURL, err := c.expandTopicURL(topic)
+ if err != nil {
+ return "", err
+ }
c.mu.Lock()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
- topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
@@ -183,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
cancel: cancel,
}
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
- return subscriptionID
+ return subscriptionID, nil
}
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
@@ -199,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) {
sub.cancel()
}
-// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
-// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
-//
-// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
-// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
-// config (e.g. mytopic -> https://ntfy.sh/mytopic).
-func (c *Client) UnsubscribeAll(topic string) {
- c.mu.Lock()
- defer c.mu.Unlock()
- topicURL := c.expandTopicURL(topic)
- for _, sub := range c.subscriptions {
- if sub.topicURL == topicURL {
- delete(c.subscriptions, sub.ID)
- sub.cancel()
- }
- }
-}
-
-func (c *Client) expandTopicURL(topic string) string {
+func (c *Client) expandTopicURL(topic string) (string, error) {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
- return topic
+ return topic, nil
} else if strings.Contains(topic, "/") {
- return fmt.Sprintf("https://%s", topic)
+ return fmt.Sprintf("https://%s", topic), nil
}
- return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
+ if !topicRegex.MatchString(topic) {
+ return "", fmt.Errorf("invalid topic name: %s", topic)
+ }
+ return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
}
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
diff --git a/client/client_test.go b/client/client_test.go
index a71ea5cb..f0b15a3f 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) {
defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port))
- subscriptionID := c.Subscribe("mytopic")
+ subscriptionID, _ := c.Subscribe("mytopic")
time.Sleep(time.Second)
msg, err := c.Publish("mytopic", "some message")
diff --git a/cmd/serve.go b/cmd/serve.go
index 5d5381bf..87b83dda 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -94,6 +94,11 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
)
var cmdServe = &cli.Command{
@@ -129,6 +134,11 @@ func execServe(c *cli.Context) error {
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
+ webPushPrivateKey := c.String("web-push-private-key")
+ webPushPublicKey := c.String("web-push-public-key")
+ webPushFile := c.String("web-push-file")
+ webPushEmailAddress := c.String("web-push-email-address")
+ webPushStartupQueries := c.String("web-push-startup-queries")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
@@ -183,6 +193,8 @@ func execServe(c *cli.Context) error {
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
+ } else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
+ return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
@@ -347,6 +359,11 @@ func execServe(c *cli.Context) error {
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.Version = c.App.Version
+ conf.WebPushPrivateKey = webPushPrivateKey
+ conf.WebPushPublicKey = webPushPublicKey
+ conf.WebPushFile = webPushFile
+ conf.WebPushEmailAddress = webPushEmailAddress
+ conf.WebPushStartupQueries = webPushStartupQueries
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index 2691e6a1..c85c4686 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND
$NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
- $NTFY_RAW $raw Raw JSON message
+ $NTFY_RAW $raw Raw JSON message
Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
@@ -194,7 +194,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
topicOptions = append(topicOptions, auth)
}
- subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
+ subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
+ if err != nil {
+ return err
+ }
if s.Command != "" {
cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" {
@@ -204,7 +207,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
}
}
if topic != "" {
- subscriptionID := cl.Subscribe(topic, options...)
+ subscriptionID, err := cl.Subscribe(topic, options...)
+ if err != nil {
+ return err
+ }
cmds[subscriptionID] = command
}
for m := range cl.Messages {
diff --git a/cmd/webpush.go b/cmd/webpush.go
new file mode 100644
index 00000000..ec66f083
--- /dev/null
+++ b/cmd/webpush.go
@@ -0,0 +1,48 @@
+//go:build !noserver
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/SherClockHolmes/webpush-go"
+ "github.com/urfave/cli/v2"
+)
+
+func init() {
+ commands = append(commands, cmdWebPush)
+}
+
+var cmdWebPush = &cli.Command{
+ Name: "webpush",
+ Usage: "Generate keys, in the future manage web push subscriptions",
+ UsageText: "ntfy webpush [keys]",
+ Category: categoryServer,
+
+ Subcommands: []*cli.Command{
+ {
+ Action: generateWebPushKeys,
+ Name: "keys",
+ Usage: "Generate VAPID keys to enable browser background push notifications",
+ UsageText: "ntfy webpush keys",
+ Category: categoryServer,
+ },
+ },
+}
+
+func generateWebPushKeys(c *cli.Context) error {
+ privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
+
+web-push-public-key: %s
+web-push-private-key: %s
+web-push-file: /var/cache/ntfy/webpush.db # or similar
+web-push-email-address:
+
+See https://ntfy.sh/docs/config/#web-push for details.
+`, publicKey, privateKey)
+ return err
+}
diff --git a/cmd/webpush_test.go b/cmd/webpush_test.go
new file mode 100644
index 00000000..1b364701
--- /dev/null
+++ b/cmd/webpush_test.go
@@ -0,0 +1,24 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/urfave/cli/v2"
+ "heckel.io/ntfy/server"
+)
+
+func TestCLI_WebPush_GenerateKeys(t *testing.T) {
+ app, _, _, stderr := newTestApp()
+ require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
+ require.Contains(t, stderr.String(), "Web Push keys generated.")
+}
+
+func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
+ webPushArgs := []string{
+ "ntfy",
+ "--log-level=ERROR",
+ "webpush",
+ }
+ return app.Run(append(webPushArgs, args...))
+}
diff --git a/docs/config.md b/docs/config.md
index df1f2cd6..9af79992 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -789,6 +789,57 @@ Note that the self-hosted server literally sends the message `New message` for e
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
it'll show `New message` as a popup.
+## Web Push
+[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))
+allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed.
+When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the
+user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then
+forward it to the browser.
+
+To configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`),
+a database to keep track of the browser's subscriptions, and an admin email address (you):
+
+- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
+- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
+- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
+- `web-push-startup-queries` is an optional list of queries to run on startup`
+
+Limitations:
+
+- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
+ certificate is required, as service workers will not run on origins with untrusted certificates.
+
+- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This
+ is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin.
+
+To configure VAPID keys, first generate them:
+
+```sh
+$ ntfy webpush keys
+Web Push keys generated.
+...
+```
+
+Then copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments:
+
+```yaml
+web-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+web-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890
+web-push-file: /var/cache/ntfy/webpush.db
+web-push-email-address: sysadmin@example.com
+```
+
+The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days,
+and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
+subscriptions are also removed automatically.
+
+The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
+file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
+
+Changing your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and
+if you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission.
+
## Tiers
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
@@ -1285,13 +1336,17 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
+| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate |
+| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate |
+| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
+| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
+| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h.
The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k.
## Command line options
```
-$ ntfy serve --help
NAME:
ntfy serve - Run the ntfy server
@@ -1321,8 +1376,8 @@ OPTIONS:
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
- --listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
- --listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
+ --listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
+ --listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
@@ -1343,11 +1398,12 @@ OPTIONS:
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
- --web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
+ --web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
+ --upstream-access-token value, --upstream_access_token value access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
@@ -1355,6 +1411,10 @@ OPTIONS:
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
+ --twilio-account value, --twilio_account value Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT]
+ --twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
+ --twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
+ --twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
@@ -1365,10 +1425,18 @@ OPTIONS:
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
+ --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
- --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
- --help, -h show help (default: false)
+ --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
+ --enable-metrics, --enable_metrics if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS]
+ --metrics-listen-http value, --metrics_listen_http value ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP]
+ --profile-listen-http value, --profile_listen_http value ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP]
+ --web-push-public-key value, --web_push_public_key value public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY]
+ --web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
+ --web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
+ --web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
+ --web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
+ --help, -h show help
```
-
diff --git a/docs/develop.md b/docs/develop.md
index baab3f3a..05b55773 100644
--- a/docs/develop.md
+++ b/docs/develop.md
@@ -16,7 +16,7 @@ server consists of three components:
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
build the docs.
-* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
+* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/)
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
and install all the 100,000 dependencies (*sigh*).
@@ -241,6 +241,41 @@ $ cd web
$ npm start
```
+### Testing Web Push locally
+
+Reference:
+
+#### With the dev servers
+
+1. Get web push keys `go run main.go webpush keys`
+
+2. Run the server with web push enabled
+
+ ```sh
+ go run main.go \
+ --log-level debug \
+ serve \
+ --web-push-public-key KEY \
+ --web-push-private-key KEY \
+ --web-push-email-address \
+ --web-push-file=/tmp/webpush.db
+ ```
+
+3. In `web/public/config.js`:
+
+ - Set `base_url` to `http://localhost`, This is required as web push can only be used with the server matching the `base_url`.
+
+ - Set the `web_push_public_key` correctly.
+
+4. Run `npm run start`
+
+#### With a built package
+
+1. Run `make web-build`
+
+2. Run the server (step 2 above)
+
+3. Open
### Build the docs
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
diff --git a/docs/faq.md b/docs/faq.md
index d7977a5f..8844566f 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -80,3 +80,13 @@ a proper backend. So as long as you secure your backend with ACLs, exposing the
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
appreciated.
+
+## Can I email you? Can I DM you on Discord/Matrix?
+While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
+[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
+**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
+[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
+
+I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
+in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
+may be able to help out. I hope you understand.
diff --git a/docs/install.md b/docs/install.md
index 1d284956..c7febac1 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -29,37 +29,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz
- tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz
- sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.tar.gz
+ tar zxvf ntfy_2.6.2_linux_amd64.tar.gz
+ sudo cp -a ntfy_2.6.2_linux_amd64/ntfy /usr/local/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz
- tar zxvf ntfy_2.5.0_linux_armv6.tar.gz
- sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.tar.gz
+ tar zxvf ntfy_2.6.2_linux_armv6.tar.gz
+ sudo cp -a ntfy_2.6.2_linux_armv6/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz
- tar zxvf ntfy_2.5.0_linux_armv7.tar.gz
- sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.tar.gz
+ tar zxvf ntfy_2.6.2_linux_armv7.tar.gz
+ sudo cp -a ntfy_2.6.2_linux_armv7/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz
- tar zxvf ntfy_2.5.0_linux_arm64.tar.gz
- sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.tar.gz
+ tar zxvf ntfy_2.6.2_linux_arm64.tar.gz
+ sudo cp -a ntfy_2.6.2_linux_arm64/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -109,7 +109,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -117,7 +117,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -125,7 +125,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -133,7 +133,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -143,34 +143,36 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
## Arch Linux
-ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
+ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
+You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
+build and install ntfy and keep it up to date.
```
paru -S ntfysh-bin
```
@@ -192,18 +194,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
-To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz),
+To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
-curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz
-tar zxvf ntfy_2.5.0_macOS_all.tar.gz
-sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy
+curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz > ntfy_2.6.2_darwin_all.tar.gz
+tar zxvf ntfy_2.6.2_darwin_all.tar.gz
+sudo cp -a ntfy_2.6.2_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
-cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
+cp ntfy_2.6.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -221,7 +223,7 @@ brew install ntfy
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
-To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_windows_x86_64.zip),
+To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
@@ -277,7 +279,7 @@ docker run \
Using docker-compose with non-root user and healthchecks enabled:
```yaml
-version: "2.1"
+version: "2.3"
services:
ntfy:
diff --git a/docs/integrations.md b/docs/integrations.md
index d1a4d42c..ce3bb32b 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -55,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)
- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS)
- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart)
+- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)
## CLIs + GUIs
@@ -121,9 +122,14 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)
- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)
- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)
+- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go)
+- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
+- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
## Blog + forum posts
+- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023
+- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023
- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023
- [桌面通知:ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023
- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023
diff --git a/docs/known-issues.md b/docs/known-issues.md
index f0528422..401d82a1 100644
--- a/docs/known-issues.md
+++ b/docs/known-issues.md
@@ -1,5 +1,5 @@
# Known issues
-This is an incomplete list of known issues with the ntfy server, Android app, and iOS app. You can find a complete
+This is an incomplete list of known issues with the ntfy server, web app, Android app, and iOS app. You can find a complete
list [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful
to have the prominent ones here to link to.
@@ -26,3 +26,17 @@ Be sure that in your selfhosted server:
* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**)
* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to
+
+## Firefox on Android not automatically subscribing to web push (see [#789](https://github.com/binwiederhier/ntfy/issues/789))
+ntfy defaults to web-push based subscriptions when installed as a [progressive web app](./subscribe/pwa.md). Firefox
+Android has an [open bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1796434) where it reports the PWA mode incorrectly.
+This causes ntfy to not automatically subscribe to web push, and requires you to go to the ntfy Settings page to enable
+it manually.
+
+## Safari does not play sounds for web push notifications
+Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with
+iOS 17 / Safari 17, which will be released later in 2023.
+
+## PWA on iOS sometimes crashes with an IndexedDB error (see [#787](https://github.com/binwiederhier/ntfy/issues/787))
+When resuming the installed PWA from the background, it sometimes crashes with an error from IndexedDB/Dexie, due to a
+[WebKit bug]( https://bugs.webkit.org/show_bug.cgi?id=197050). A reload will fix it until a permanent fix is found.
diff --git a/docs/releases.md b/docs/releases.md
index 71fceb1e..35e049c4 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -2,6 +2,53 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
+## ntfy server v2.6.2
+Released June 30, 2023
+
+With this release, the ntfy web app now contains a **[progressive web app](subscribe/pwa.md) (PWA)
+with Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar
+to a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window,
+push notifications, and an app badge with the unread notification count. Note that for self-hosted servers,
+[Web Push](config.md#web-push) must be configured.
+
+On top of that, this release also brings **dark mode** 🧛🌙 to the web app.
+
+🙏 A huge thanks for this release goes to [@nimbleghost](https://github.com/nimbleghost), for basically implementing the
+Web Push / PWA and dark mode feature by himself. I'm really grateful for your contributions.
+
+❤️ If you like ntfy, **please consider sponsoring us** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
+and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app) (20% off
+if you use promo code `MYTOPIC`). ntfy will always remain open source.
+
+**Features:**
+
+* The web app now supports Web Push, and is installable as a [progressive web app (PWA)](https://docs.ntfy.sh/subscribe/pwa/) on Chrome, Edge, Android, and iOS ([#751](https://github.com/binwiederhier/ntfy/pull/751), thanks to [@nimbleghost](https://github.com/nimbleghost))
+* Support for dark mode in the web app ([#206](https://github.com/binwiederhier/ntfy/issues/206), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
+**Bug fixes:**
+
+* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
+* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)
+* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
+* Newly created access tokens are now lowercase only to fully support `+@` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting)
+* The .1 release fixes a few visual issues with dark mode, and other web app updates ([#791](https://github.com/binwiederhier/ntfy/pull/791), [#793](https://github.com/binwiederhier/ntfy/pull/793), [#792](https://github.com/binwiederhier/ntfy/pull/792), thanks to [@nimbleghost](https://github.com/nimbleghost))
+* The .2 release fixes issues with the service worker in Firefox and adds automatic service worker updates ([#795](https://github.com/binwiederhier/ntfy/pull/795), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
+**Maintenance:**
+
+* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
+* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
+* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
+* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
+**Changes in tarball/zip naming:**
+Due to a [change in GoReleaser](https://goreleaser.com/deprecations/#archivesreplacements), some of the binary release
+archives now have slightly different names. My apologies if this causes issues in the downstream projects that use ntfy:
+
+- `ntfy_v${VERSION}_windows_x86_64.zip` -> `ntfy_v${VERSION}_windows_amd64.zip`
+- `ntfy_v${VERSION}_linux_x86_64.tar.gz` -> `ntfy_v${VERSION}_linux_amd64.tar.gz`
+- `ntfy_v${VERSION}_macOS_all.tar.gz` -> `ntfy_v${VERSION}_darwin_all.tar.gz`
+
## ntfy server v2.5.0
Released May 18, 2023
@@ -1204,6 +1251,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
+### ntfy server v2.7.0 (UNRELEASED)
+
+**Features:**
+
+* Add support for right-to-left languages (RTL) in the web app ([#663](https://github.com/binwiederhier/ntfy/issues/663), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
+**Bug fixes + maintenance:**
+
+* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost))
+
### ntfy Android app v1.16.1 (UNRELEASED)
**Features:**
@@ -1219,16 +1276,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Additional languages:**
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
-
-### ntfy server v2.6.0 (UNRELEASED)
-
-**Bug fixes:**
-
-* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
-
-**Maintenance:**
-
-* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
-* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
-* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
-* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
diff --git a/docs/static/img/pwa-badge.png b/docs/static/img/pwa-badge.png
new file mode 100644
index 00000000..1a22de07
Binary files /dev/null and b/docs/static/img/pwa-badge.png differ
diff --git a/docs/static/img/pwa-install-chrome-android-menu.jpg b/docs/static/img/pwa-install-chrome-android-menu.jpg
new file mode 100644
index 00000000..1c258d64
Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-menu.jpg differ
diff --git a/docs/static/img/pwa-install-chrome-android-popup.jpg b/docs/static/img/pwa-install-chrome-android-popup.jpg
new file mode 100644
index 00000000..90c77c77
Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android-popup.jpg differ
diff --git a/docs/static/img/pwa-install-chrome-android.jpg b/docs/static/img/pwa-install-chrome-android.jpg
new file mode 100644
index 00000000..7f89503f
Binary files /dev/null and b/docs/static/img/pwa-install-chrome-android.jpg differ
diff --git a/docs/static/img/pwa-install-firefox-android-menu.jpg b/docs/static/img/pwa-install-firefox-android-menu.jpg
new file mode 100644
index 00000000..c0aa59e7
Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-menu.jpg differ
diff --git a/docs/static/img/pwa-install-firefox-android-popup.jpg b/docs/static/img/pwa-install-firefox-android-popup.jpg
new file mode 100644
index 00000000..e97a858d
Binary files /dev/null and b/docs/static/img/pwa-install-firefox-android-popup.jpg differ
diff --git a/docs/static/img/pwa-install-safari-ios-add-icon.jpg b/docs/static/img/pwa-install-safari-ios-add-icon.jpg
new file mode 100644
index 00000000..175fb8b4
Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-add-icon.jpg differ
diff --git a/docs/static/img/pwa-install-safari-ios-button.jpg b/docs/static/img/pwa-install-safari-ios-button.jpg
new file mode 100644
index 00000000..c9897c30
Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-button.jpg differ
diff --git a/docs/static/img/pwa-install-safari-ios-menu.jpg b/docs/static/img/pwa-install-safari-ios-menu.jpg
new file mode 100644
index 00000000..b6408afd
Binary files /dev/null and b/docs/static/img/pwa-install-safari-ios-menu.jpg differ
diff --git a/docs/static/img/pwa-install.png b/docs/static/img/pwa-install.png
new file mode 100644
index 00000000..c44e7dbc
Binary files /dev/null and b/docs/static/img/pwa-install.png differ
diff --git a/docs/static/img/pwa.png b/docs/static/img/pwa.png
new file mode 100644
index 00000000..c26f29f1
Binary files /dev/null and b/docs/static/img/pwa.png differ
diff --git a/docs/static/img/web-pin.png b/docs/static/img/web-pin.png
deleted file mode 100644
index 3312a50f..00000000
Binary files a/docs/static/img/web-pin.png and /dev/null differ
diff --git a/docs/static/img/web-subscribe.png b/docs/static/img/web-subscribe.png
index f60a8658..ccbd0493 100644
Binary files a/docs/static/img/web-subscribe.png and b/docs/static/img/web-subscribe.png differ
diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md
index 440dbbe3..e88ff0fb 100644
--- a/docs/subscribe/phone.md
+++ b/docs/subscribe/phone.md
@@ -12,6 +12,9 @@ You can get the Android app from both [Google Play](https://play.google.com/stor
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
+Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
+The PWA is a website that you can add to your home screen, and it will behave just like a native app.
+
## Overview
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
diff --git a/docs/subscribe/pwa.md b/docs/subscribe/pwa.md
new file mode 100644
index 00000000..582cb5ae
--- /dev/null
+++ b/docs/subscribe/pwa.md
@@ -0,0 +1,62 @@
+# Using the progressive web app (PWA)
+While ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA)
+and thus can be **installed on both desktop and mobile devices**.
+
+This gives it its own launcher (e.g. shortcut on Windows, app on macOS, launcher shortcut on Linux, home screen icon on iOS, and
+launcher icon on Android), a standalone window, push notifications, and an app badge with the unread notification count.
+
+Web app installation is **supported on** (see [compatibility table](https://caniuse.com/web-app-manifest) for details):
+
+- **Chrome:** Android, Windows, Linux, macOS
+- **Safari:** iOS 16.4+, macOS 14+
+- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/)
+- **Edge:** Windows
+
+Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work.
+
+## Installation
+
+### Chrome on Desktop
+To install and register the web app via Chrome, click the "install app" icon. After installation, you can find the app in your
+app drawer:
+
+
+
+
+
+
+
+### Chrome/Firefox on Android
+For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app"
+in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer,
+and on your home screen.
+
+
+
+
+
+
+
+For Firefox, select "Install" in the menu, and then click "Add" to add an icon to your home screen:
+
+
+
+
+
+
+### Safari on iOS
+On iOS Safari, tap on the Share menu, then tap "Add to Home Screen":
+
+
+
+
+
+
+
+## Background notifications
+Background notifications via web push are enabled by default and cannot be turned off when the app is installed, as notifications would
+not be delivered reliably otherwise. You can mute topics you don't want to receive notifications for.
+
+On desktop, you generally need either your browser or the web app open to receive notifications, though the ntfy tab doesn't need to be
+open. On mobile, you don't need to have the web app open to receive notifications. Look at the [web docs](./web.md#background-notifications)
+for a detailed breakdown.
diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md
index 5c2672f0..859f7d0a 100644
--- a/docs/subscribe/web.md
+++ b/docs/subscribe/web.md
@@ -1,27 +1,75 @@
-# Subscribe from the Web UI
-You can use the Web UI to subscribe to topics as well. If you do, and you keep the website open, **notifications will
-pop up as desktop notifications**. Simply type in the topic name and click the *Subscribe* button. The browser will
-keep a connection open and listen for incoming notifications.
+# Subscribe from the web app
+The web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app).
+To subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic
+will appear in the web app, and pop up as a notification.**
+
+
+
+
+## Publish messages
To learn how to send messages, check out the [publishing page](../publish.md).
-
-To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
-is to pin the tab so that it's always open, but sort of out of the way:
-
-
-
+## Topic reservations
If topic reservations are enabled, you can claim ownership over topics and define access to it:
+
+## Notification features and browser support
+
+- Emoji tags are supported in all browsers
+
+- [Click](../publish.md#click-action) actions are supported in all browsers
+
+- Only Chrome, Edge, and Opera support displaying view and http [actions](../publish.md#action-buttons) in notifications.
+
+ Their presentation is platform specific.
+
+ Note that HTTP actions are performed using fetch and thus are limited to the [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
+ rules, which means that any URL you include needs to respond to a [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request)
+ with headers allowing the origin of the ntfy web app (`Access-Control-Allow-Origin: https://ntfy.sh`) or `*`.
+
+- Only Chrome, Edge, and Opera support displaying [images](../publish.md#attachments) in notifications.
+
+Look at the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility)
+for more info.
+
+## Background notifications
+While subscribing, you have the option to enable background notifications on supported browsers (see "Settings" tab).
+
+Note: If you add the web app to your homescreen (as a progressive web app, more info in the [installed web app](pwa.md)
+docs), you cannot turn these off, as notifications would not be delivered reliably otherwise. You can mute topics you don't want to receive
+notifications for.
+
+**If background notifications are off:** This requires an active ntfy tab to be open to receive notifications.
+These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser
+is allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications,
+**pinning the ntfy tab on your browser** is a good solution to leave it running.
+
+**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active
+ntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the
+same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself.
+
+If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them
+by opening the app again, and will get a warning notification before they are paused.
+
+| Browser | Platform | Browser Running | Browser Not Running | Restrictions |
+|---------|----------|-----------------|---------------------|---------------------------------------------------------|
+| Chrome | Desktop | ✅ | ❌ | |
+| Firefox | Desktop | ✅ | ❌ | |
+| Edge | Desktop | ✅ | ❌ | |
+| Opera | Desktop | ✅ | ❌ | |
+| Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura |
+| Chrome | Android | ✅ | ✅ | |
+| Firefox | Android | ✅ | ✅ | |
+| Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen |
+
+(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info)
diff --git a/go.mod b/go.mod
index 162fd943..7c53b10a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,23 +3,23 @@ module heckel.io/ntfy
go 1.18
require (
- cloud.google.com/go/firestore v1.9.0 // indirect
- cloud.google.com/go/storage v1.30.1 // indirect
- github.com/BurntSushi/toml v1.2.1 // indirect
+ cloud.google.com/go/firestore v1.11.0 // indirect
+ cloud.google.com/go/storage v1.31.0 // indirect
+ github.com/BurntSushi/toml v1.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/gorilla/websocket v1.5.0
- github.com/mattn/go-sqlite3 v1.14.16
- github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
+ github.com/mattn/go-sqlite3 v1.14.17
+ github.com/olebedev/when v1.0.0
github.com/stretchr/testify v1.8.1
- github.com/urfave/cli/v2 v2.25.3
- golang.org/x/crypto v0.9.0
- golang.org/x/oauth2 v0.8.0 // indirect
- golang.org/x/sync v0.2.0
- golang.org/x/term v0.8.0
+ github.com/urfave/cli/v2 v2.25.7
+ golang.org/x/crypto v0.10.0
+ golang.org/x/oauth2 v0.9.0 // indirect
+ golang.org/x/sync v0.3.0
+ golang.org/x/term v0.9.0
golang.org/x/time v0.3.0
- google.golang.org/api v0.122.0
+ google.golang.org/api v0.129.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -27,48 +27,52 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.11.0
- github.com/prometheus/client_golang v1.15.1
- github.com/stripe/stripe-go/v74 v74.18.0
+ github.com/SherClockHolmes/webpush-go v1.2.0
+ github.com/prometheus/client_golang v1.16.0
+ github.com/stripe/stripe-go/v74 v74.24.0
)
require (
- cloud.google.com/go v0.110.2 // indirect
- cloud.google.com/go/compute v1.19.3 // indirect
+ cloud.google.com/go v0.110.3 // indirect
+ cloud.google.com/go/compute v1.20.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
- cloud.google.com/go/iam v1.0.1 // indirect
- cloud.google.com/go/longrunning v0.4.2 // indirect
+ cloud.google.com/go/iam v1.1.1 // indirect
+ cloud.google.com/go/longrunning v0.5.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
+ github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
- github.com/google/s2a-go v0.1.3 // indirect
+ github.com/google/s2a-go v0.1.4 // indirect
github.com/google/uuid v1.3.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
- github.com/googleapis/gax-go/v2 v2.8.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect
+ github.com/googleapis/gax-go/v2 v2.11.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
- github.com/prometheus/common v0.43.0 // indirect
- github.com/prometheus/procfs v0.9.0 // indirect
+ github.com/prometheus/common v0.44.0 // indirect
+ github.com/prometheus/procfs v0.11.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
- golang.org/x/net v0.10.0 // indirect
- golang.org/x/sys v0.8.0 // indirect
- golang.org/x/text v0.9.0 // indirect
+ golang.org/x/net v0.11.0 // indirect
+ golang.org/x/sys v0.10.0 // indirect
+ golang.org/x/text v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.3 // indirect
- google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
- google.golang.org/grpc v1.55.0 // indirect
- google.golang.org/protobuf v1.30.0 // indirect
+ google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
+ google.golang.org/grpc v1.56.1 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index bfaf339d..d5685ee8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,28 +1,30 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
-cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
-cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
-cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
+cloud.google.com/go v0.110.3 h1:wwearW+L7sAPSomPIgJ3bVn6Ck00HGQnn5HMLwf0azo=
+cloud.google.com/go v0.110.3/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
+cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
+cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
-cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
-cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
-cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
-cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
-cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
-cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
-cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
+cloud.google.com/go/firestore v1.11.0 h1:PPgtwcYUOXV2jFe1bV3nda3RCrOa8cvBjTOn2MQVfW8=
+cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4=
+cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
+cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
+cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
+cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
+cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
+cloud.google.com/go/storage v1.31.0/go.mod h1:81ams1PrhW16L4kF7qg+4mTq7SRs5HsbDTM0bWvrwJ0=
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
-github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
+github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
+github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -57,6 +59,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -91,40 +95,40 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
-github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE=
-github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
+github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
+github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
-github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
-github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
-github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
+github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM=
+github.com/googleapis/enterprise-certificate-proxy v0.2.5/go.mod h1:RxW0N9901Cko1VOCW3SXCpWP+mlIEkk2tP7jnHy9a3w=
+github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
+github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
-github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
+github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
-github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
-github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
+github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
+github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
-github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
+github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
+github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
-github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us=
-github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc=
-github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
-github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
+github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
+github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
+github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
+github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -139,22 +143,23 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
-github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
-github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
-github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
+github.com/stripe/stripe-go/v74 v74.24.0 h1:h+hXEI5avC5moAh2YLtphMFTBnp11TfXTcP4suuWDLk=
+github.com/stripe/stripe-go/v74 v74.24.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
+github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
+github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
-golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
+golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
+golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -174,19 +179,19 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
+golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
-golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
+golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs=
+golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
-golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -197,20 +202,24 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
+golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
+golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -225,8 +234,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
-google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
+google.golang.org/api v0.129.0 h1:2XbdjjNfFPXQyufzQVwPf1RRnHH8Den2pfNE2jw7L8w=
+google.golang.org/api v0.129.0/go.mod h1:dFjiXlanKwWE3612X97llhsoI36FAoIiRj3aTl5b/zE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -237,8 +246,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
-google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
+google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs=
+google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
+google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
+google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
@@ -247,8 +260,8 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
-google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
-google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
+google.golang.org/grpc v1.56.1 h1:z0dNfjIl0VpaZ9iSVjA6daGatAYwPGstTjt5vkRMFkQ=
+google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -260,8 +273,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/mkdocs.yml b/mkdocs.yml
index 4a7db366..7b14ee0c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -82,6 +82,7 @@ nav:
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md
+ - "From the Desktop": subscribe/pwa.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
diff --git a/server/config.go b/server/config.go
index a876926e..9815aa88 100644
--- a/server/config.go
+++ b/server/config.go
@@ -1,10 +1,11 @@
package server
import (
- "heckel.io/ntfy/user"
"io/fs"
"net/netip"
"time"
+
+ "heckel.io/ntfy/user"
)
// Defines default config settings (excluding limits, see below)
@@ -22,6 +23,12 @@ const (
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
)
+// Defines default Web Push settings
+const (
+ DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour
+ DefaultWebPushExpiryDuration = 9 * 24 * time.Hour
+)
+
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall
@@ -146,6 +153,13 @@ type Config struct {
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
+ WebPushPrivateKey string
+ WebPushPublicKey string
+ WebPushFile string
+ WebPushEmailAddress string
+ WebPushStartupQueries string
+ WebPushExpiryDuration time.Duration
+ WebPushExpiryWarningDuration time.Duration
}
// NewConfig instantiates a default new server config
@@ -227,5 +241,11 @@ func NewConfig() *Config {
EnableReservations: false,
AccessControlAllowOrigin: "*",
Version: "",
+ WebPushPrivateKey: "",
+ WebPushPublicKey: "",
+ WebPushFile: "",
+ WebPushEmailAddress: "",
+ WebPushExpiryDuration: DefaultWebPushExpiryDuration,
+ WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
}
}
diff --git a/server/errors.go b/server/errors.go
index eee916b5..27ba3df0 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -114,6 +114,9 @@ var (
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
+ errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
+ errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
+ errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
@@ -138,5 +141,6 @@ var (
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
+ errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
)
diff --git a/server/log.go b/server/log.go
index c638ed97..978d0593 100644
--- a/server/log.go
+++ b/server/log.go
@@ -29,6 +29,7 @@ const (
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
+ tagWebPush = "webpush"
)
var (
diff --git a/server/message_cache.go b/server/message_cache.go
index 1d7302af..140271fe 100644
--- a/server/message_cache.go
+++ b/server/message_cache.go
@@ -270,7 +270,7 @@ func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration
if err != nil {
return nil, err
}
- if err := setupDB(db, startupQueries, cacheDuration); err != nil {
+ if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
@@ -749,7 +749,7 @@ func (c *messageCache) Close() error {
return c.db.Close()
}
-func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
+func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
diff --git a/server/server.go b/server/server.go
index 179359d1..60a2fb30 100644
--- a/server/server.go
+++ b/server/server.go
@@ -9,13 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/emersion/go-smtp"
- "github.com/gorilla/websocket"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- "golang.org/x/sync/errgroup"
- "heckel.io/ntfy/log"
- "heckel.io/ntfy/user"
- "heckel.io/ntfy/util"
"io"
"net"
"net/http"
@@ -32,6 +25,14 @@ import (
"sync"
"time"
"unicode/utf8"
+
+ "github.com/emersion/go-smtp"
+ "github.com/gorilla/websocket"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ "golang.org/x/sync/errgroup"
+ "heckel.io/ntfy/log"
+ "heckel.io/ntfy/user"
+ "heckel.io/ntfy/util"
)
// Server is the main server, providing the UI and API for ntfy
@@ -52,6 +53,7 @@ type Server struct {
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
+ webPush *webPushStore // Database that stores web push subscriptions
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
@@ -76,11 +78,15 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
+ webManifestPath = "/manifest.webmanifest"
+ webRootHTMLPath = "/app.html"
+ webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiStatsPath = "/v1/stats"
+ apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
@@ -151,6 +157,13 @@ func New(conf *Config) (*Server, error) {
if err != nil {
return nil, err
}
+ var webPush *webPushStore
+ if conf.WebPushPublicKey != "" {
+ webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
+ if err != nil {
+ return nil, err
+ }
+ }
topics, err := messageCache.Topics()
if err != nil {
return nil, err
@@ -190,6 +203,7 @@ func New(conf *Config) (*Server, error) {
s := &Server{
config: conf,
messageCache: messageCache,
+ webPush: webPush,
fileCache: fileCache,
firebaseClient: firebaseClient,
smtpSender: mailer,
@@ -342,6 +356,9 @@ func (s *Server) closeDatabases() {
s.userManager.Close()
}
s.messageCache.Close()
+ if s.webPush != nil {
+ s.webPush.Close()
+ }
}
// handle is the main entry point for all HTTP requests
@@ -416,6 +433,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleHealth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
+ } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
+ return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
@@ -470,6 +489,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
+ } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
+ return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
+ } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
+ return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushDelete))(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@@ -478,7 +501,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
- } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
+ } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@@ -552,7 +575,9 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableReservations: s.config.EnableReservations,
+ EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
+ WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
}
b, err := json.MarshalIndent(response, "", " ")
@@ -564,6 +589,25 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
return err
}
+// handleWebManifest serves the web app manifest for the progressive web app (PWA)
+func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+ response := &webManifestResponse{
+ Name: "ntfy web",
+ Description: "ntfy lets you send push notifications via scripts from any computer or phone",
+ ShortName: "ntfy",
+ Scope: "/",
+ StartURL: s.config.WebRoot,
+ Display: "standalone",
+ BackgroundColor: "#ffffff",
+ ThemeColor: "#317f6f",
+ Icons: []*webManifestIcon{
+ {SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
+ {SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
+ },
+ }
+ return s.writeJSONWithContentType(w, response, "application/manifest+json")
+}
+
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
// and listen-metrics-http is not set.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@@ -760,9 +804,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call)
}
- if s.config.UpstreamBaseURL != "" {
+ if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
}
+ if s.config.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
} else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@@ -1696,6 +1743,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
+ if s.config.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
if err := s.messageCache.MarkPublished(m); err != nil {
return err
}
@@ -1916,7 +1966,11 @@ func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
}
func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
- w.Header().Set("Content-Type", "application/json")
+ return s.writeJSONWithContentType(w, v, "application/json")
+}
+
+func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
+ w.Header().Set("Content-Type", contentType)
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(v); err != nil {
return err
diff --git a/server/server.yml b/server/server.yml
index 9c7972e9..6b2fc989 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -144,6 +144,27 @@
# smtp-server-domain:
# smtp-server-addr-prefix:
+# Web Push support (background notifications for browsers)
+#
+# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users
+# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
+# endpoint, which will then forward it to the browser.
+#
+# You must configure web-push-public/private key, web-push-file, and web-push-email-address below to enable Web Push.
+# Run "ntfy webpush keys" to generate the keys.
+#
+# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
+# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
+# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
+# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com`
+# - web-push-startup-queries is an optional list of queries to run on startup`
+#
+# web-push-public-key:
+# web-push-private-key:
+# web-push-file:
+# web-push-email-address:
+# web-push-startup-queries:
+
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
diff --git a/server/server_account.go b/server/server_account.go
index 6e6a6864..f26cc2ff 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -170,6 +170,11 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation
}
+ if s.webPush != nil && u.ID != "" {
+ if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
+ logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
+ }
+ }
if u.Billing.StripeSubscriptionID != "" {
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
diff --git a/server/server_manager.go b/server/server_manager.go
index 52e3621e..66d449de 100644
--- a/server/server_manager.go
+++ b/server/server_manager.go
@@ -15,6 +15,7 @@ func (s *Server) execManager() {
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
+ s.pruneAndNotifyWebPushSubscriptions()
// Message count per topic
var messagesCached int
diff --git a/server/server_middleware.go b/server/server_middleware.go
index 7aea45a3..b1428154 100644
--- a/server/server_middleware.go
+++ b/server/server_middleware.go
@@ -58,6 +58,15 @@ func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
}
}
+func (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc {
+ return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ if s.config.WebRoot == "" || s.config.WebPushPublicKey == "" {
+ return errHTTPNotFound
+ }
+ return next(w, r, v)
+ }
+}
+
func (s *Server) ensureUserManager(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.userManager == nil {
diff --git a/server/server_test.go b/server/server_test.go
index 73df2762..e9ff6fcb 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -22,6 +22,7 @@ import (
"testing"
"time"
+ "github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
@@ -238,6 +239,12 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s, "GET", "/config.js", "", nil)
require.Equal(t, 404, rr.Code)
+ rr = request(t, s, "GET", "/sw.js", "", nil)
+ require.Equal(t, 404, rr.Code)
+
+ rr = request(t, s, "GET", "/app.html", "", nil)
+ require.Equal(t, 404, rr.Code)
+
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 404, rr.Code)
@@ -250,6 +257,35 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s2, "GET", "/config.js", "", nil)
require.Equal(t, 200, rr.Code)
+
+ rr = request(t, s2, "GET", "/sw.js", "", nil)
+ require.Equal(t, 200, rr.Code)
+
+ rr = request(t, s2, "GET", "/app.html", "", nil)
+ require.Equal(t, 200, rr.Code)
+}
+
+func TestServer_WebPushEnabled(t *testing.T) {
+ conf := newTestConfig(t)
+ conf.WebRoot = "" // Disable web app
+ s := newTestServer(t, conf)
+
+ rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
+ require.Equal(t, 404, rr.Code)
+
+ conf2 := newTestConfig(t)
+ s2 := newTestServer(t, conf2)
+
+ rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
+ require.Equal(t, 404, rr.Code)
+
+ conf3 := newTestConfigWithWebPush(t)
+ s3 := newTestServer(t, conf3)
+
+ rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
+ require.Equal(t, 200, rr.Code)
+ require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
+
}
func TestServer_PublishLargeMessage(t *testing.T) {
@@ -2559,6 +2595,29 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) {
})
}
+func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) {
+ upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("UnifiedPush messages should not be forwarded")
+ }))
+ defer upstreamServer.Close()
+
+ c := newTestConfigWithAuthFile(t)
+ c.BaseURL = "http://myserver.internal"
+ c.UpstreamBaseURL = upstreamServer.URL
+ s := newTestServer(t, c)
+
+ // Send UP message, this should not forward to upstream server
+ response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil)
+ require.Equal(t, 200, response.Code)
+ m := toMessage(t, response.Body.String())
+ require.NotEmpty(t, m.ID)
+ require.Equal(t, "hi there", m.Message)
+
+ // Forwarding is done asynchronously, so wait a bit.
+ // This ensures that the t.Fatal above is actually not triggered.
+ time.Sleep(500 * time.Millisecond)
+}
+
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
@@ -2568,19 +2627,33 @@ func newTestConfig(t *testing.T) *Config {
return conf
}
-func newTestConfigWithAuthFile(t *testing.T) *Config {
- conf := newTestConfig(t)
+func configureAuth(t *testing.T, conf *Config) *Config {
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot
return conf
}
+func newTestConfigWithAuthFile(t *testing.T) *Config {
+ conf := newTestConfig(t)
+ conf = configureAuth(t, conf)
+ return conf
+}
+
+func newTestConfigWithWebPush(t *testing.T) *Config {
+ conf := newTestConfig(t)
+ privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+ require.Nil(t, err)
+ conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
+ conf.WebPushEmailAddress = "testing@example.com"
+ conf.WebPushPrivateKey = privateKey
+ conf.WebPushPublicKey = publicKey
+ return conf
+}
+
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
- if err != nil {
- t.Fatal(err)
- }
+ require.Nil(t, err)
return server
}
diff --git a/server/server_webpush.go b/server/server_webpush.go
new file mode 100644
index 00000000..bb0f5408
--- /dev/null
+++ b/server/server_webpush.go
@@ -0,0 +1,171 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/SherClockHolmes/webpush-go"
+ "heckel.io/ntfy/log"
+ "heckel.io/ntfy/user"
+)
+
+const (
+ webPushTopicSubscribeLimit = 50
+)
+
+var (
+ webPushAllowedEndpointsPatterns = []string{
+ "https://*.google.com/",
+ "https://*.googleapis.com/",
+ "https://*.mozilla.com/",
+ "https://*.mozaws.net/",
+ "https://*.windows.com/",
+ "https://*.microsoft.com/",
+ "https://*.apple.com/",
+ }
+ webPushAllowedEndpointsRegex *regexp.Regexp
+)
+
+func init() {
+ for i, pattern := range webPushAllowedEndpointsPatterns {
+ webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+")
+ }
+ allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|"))
+ webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)
+}
+
+func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" {
+ return errHTTPBadRequestWebPushSubscriptionInvalid
+ } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {
+ return errHTTPBadRequestWebPushEndpointUnknown
+ } else if len(req.Topics) > webPushTopicSubscribeLimit {
+ return errHTTPBadRequestWebPushTopicCountTooHigh
+ }
+ topics, err := s.topicsFromIDs(req.Topics...)
+ if err != nil {
+ return err
+ }
+ if s.userManager != nil {
+ u := v.User()
+ for _, t := range topics {
+ if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {
+ logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
+ return errHTTPForbidden.With(t)
+ }
+ }
+ }
+ if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
+ req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil || req.Endpoint == "" {
+ return errHTTPBadRequestWebPushSubscriptionInvalid
+ }
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(req.Endpoint); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
+ subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
+ if err != nil {
+ logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
+ return
+ }
+ log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
+ payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
+ if err != nil {
+ log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
+ return
+ }
+ for _, subscription := range subscriptions {
+ if err := s.sendWebPushNotification(subscription, payload, v, m); err != nil {
+ log.Tag(tagWebPush).Err(err).With(v, m, subscription).Warn("Unable to publish web push message")
+ }
+ }
+}
+
+func (s *Server) pruneAndNotifyWebPushSubscriptions() {
+ if s.config.WebPushPublicKey == "" {
+ return
+ }
+ go func() {
+ if err := s.pruneAndNotifyWebPushSubscriptionsInternal(); err != nil {
+ log.Tag(tagWebPush).Err(err).Warn("Unable to prune or notify web push subscriptions")
+ }
+ }()
+}
+
+func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
+ // Expire old subscriptions
+ if err := s.webPush.RemoveExpiredSubscriptions(s.config.WebPushExpiryDuration); err != nil {
+ return err
+ }
+ // Notify subscriptions that will expire soon
+ subscriptions, err := s.webPush.SubscriptionsExpiring(s.config.WebPushExpiryWarningDuration)
+ if err != nil {
+ return err
+ } else if len(subscriptions) == 0 {
+ return nil
+ }
+ payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload())
+ if err != nil {
+ return err
+ }
+ warningSent := make([]*webPushSubscription, 0)
+ for _, subscription := range subscriptions {
+ if err := s.sendWebPushNotification(subscription, payload); err != nil {
+ log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
+ continue
+ }
+ warningSent = append(warningSent, subscription)
+ }
+ if err := s.webPush.MarkExpiryWarningSent(warningSent); err != nil {
+ return err
+ }
+ log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions))
+ return nil
+}
+
+func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
+ payload := &webpush.Subscription{
+ Endpoint: sub.Endpoint,
+ Keys: webpush.Keys{
+ Auth: sub.Auth,
+ P256dh: sub.P256dh,
+ },
+ }
+ resp, err := webpush.SendNotification(message, payload, &webpush.Options{
+ Subscriber: s.config.WebPushEmailAddress,
+ VAPIDPublicKey: s.config.WebPushPublicKey,
+ VAPIDPrivateKey: s.config.WebPushPrivateKey,
+ Urgency: webpush.UrgencyHigh, // iOS requires this to ensure delivery
+ TTL: int(s.config.CacheDuration.Seconds()),
+ })
+ if err != nil {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Err(err).Debug("Unable to publish web push message, removing endpoint")
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
+ return err
+ }
+ return err
+ }
+ if (resp.StatusCode < 200 || resp.StatusCode > 299) && resp.StatusCode != 429 {
+ log.Tag(tagWebPush).With(sub).With(contexters...).Field("response_code", resp.StatusCode).Debug("Unable to publish web push message, unexpected response")
+ if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
+ return err
+ }
+ return errHTTPInternalErrorWebPushUnableToPublish.With(sub).With(contexters...)
+ }
+ return nil
+}
diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go
new file mode 100644
index 00000000..c0db79c6
--- /dev/null
+++ b/server/server_webpush_test.go
@@ -0,0 +1,256 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/stretchr/testify/require"
+ "heckel.io/ntfy/user"
+ "heckel.io/ntfy/util"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+const (
+ testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
+)
+
+func TestServer_WebPush_Disabled(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_WebPush_TopicAdd(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+
+ require.Len(t, subs, 1)
+ require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
+ require.Equal(t, subs[0].P256dh, "p256dh-key")
+ require.Equal(t, subs[0].Auth, "auth-key")
+ require.Equal(t, subs[0].UserID, "")
+}
+
+func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
+}
+
+func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ topicList := make([]string, 51)
+ for i := range topicList {
+ topicList[i] = util.RandomString(5)
+ }
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
+}
+
+func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ addSubscription(t, s, testWebPushEndpoint, "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_Delete(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ addSubscription(t, s, testWebPushEndpoint, "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ config.AuthDefault = user.PermissionDenyAll
+ s := newTestServer(t, config)
+
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+ require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
+}
+
+func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ config.AuthDefault = user.PermissionDenyAll
+ s := newTestServer(t, config)
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
+ require.Equal(t, 403, response.Code)
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
+ config := configureAuth(t, newTestConfigWithWebPush(t))
+ s := newTestServer(t, config)
+
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+ require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
+
+ response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ // should've been deleted with the account
+ requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
+func TestServer_WebPush_Publish(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ require.Equal(t, "/push-receive", r.URL.Path)
+ require.Equal(t, "high", r.Header.Get("Urgency"))
+ require.Equal(t, "", r.Header.Get("Topic"))
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
+ request(t, s, "POST", "/test-topic", "web push test", nil)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+}
+
+func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ w.WriteHeader(http.StatusGone)
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+ requireSubscriptionCount(t, s, "test-topic-abc", 1)
+
+ request(t, s, "POST", "/test-topic", "web push test", nil)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+
+ // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
+
+ requireSubscriptionCount(t, s, "test-topic", 0)
+ requireSubscriptionCount(t, s, "test-topic-abc", 0)
+}
+
+func TestServer_WebPush_Expiry(t *testing.T) {
+ s := newTestServer(t, newTestConfigWithWebPush(t))
+
+ var received atomic.Bool
+
+ pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ w.WriteHeader(200)
+ w.Write([]byte(``))
+ received.Store(true)
+ }))
+ defer pushService.Close()
+
+ addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ _, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix())
+ require.Nil(t, err)
+
+ s.pruneAndNotifyWebPushSubscriptions()
+ requireSubscriptionCount(t, s, "test-topic", 1)
+
+ waitFor(t, func() bool {
+ return received.Load()
+ })
+
+ _, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix())
+ require.Nil(t, err)
+
+ s.pruneAndNotifyWebPushSubscriptions()
+ waitFor(t, func() bool {
+ subs, err := s.webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ return len(subs) == 0
+ })
+}
+
+func payloadForTopics(t *testing.T, topics []string, endpoint string) string {
+ topicsJSON, err := json.Marshal(topics)
+ require.Nil(t, err)
+
+ return fmt.Sprintf(`{
+ "topics": %s,
+ "endpoint": "%s",
+ "p256dh": "p256dh-key",
+ "auth": "auth-key"
+ }`, topicsJSON, endpoint)
+}
+
+func addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) {
+ require.Nil(t, s.webPush.UpsertSubscription(endpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", "u_123", netip.MustParseAddr("1.2.3.4"), topics)) // Test auth and p256dh
+}
+
+func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) {
+ subs, err := s.webPush.SubscriptionsForTopic(topic)
+ require.Nil(t, err)
+ require.Len(t, subs, expectedLength)
+}
diff --git a/server/types.go b/server/types.go
index 7798a65e..279f4ce8 100644
--- a/server/types.go
+++ b/server/types.go
@@ -1,12 +1,13 @@
package server
import (
- "heckel.io/ntfy/log"
- "heckel.io/ntfy/user"
"net/http"
"net/netip"
"time"
+ "heckel.io/ntfy/log"
+ "heckel.io/ntfy/user"
+
"heckel.io/ntfy/util"
)
@@ -41,7 +42,7 @@ type message struct {
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
- User string `json:"-"` // Username of the uploader, used to associated attachments
+ User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
@@ -398,7 +399,9 @@ type apiConfigResponse struct {
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableReservations bool `json:"enable_reservations"`
+ EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
+ WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
}
@@ -463,3 +466,75 @@ type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"`
Customer string `json:"customer"`
}
+
+type apiWebPushUpdateSubscriptionRequest struct {
+ Endpoint string `json:"endpoint"`
+ Auth string `json:"auth"`
+ P256dh string `json:"p256dh"`
+ Topics []string `json:"topics"`
+}
+
+// List of possible Web Push events (see sw.js)
+const (
+ webPushMessageEvent = "message"
+ webPushExpiringEvent = "subscription_expiring"
+)
+
+type webPushPayload struct {
+ Event string `json:"event"`
+ SubscriptionID string `json:"subscription_id"`
+ Message *message `json:"message"`
+}
+
+func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
+ return &webPushPayload{
+ Event: webPushMessageEvent,
+ SubscriptionID: subscriptionID,
+ Message: message,
+ }
+}
+
+type webPushControlMessagePayload struct {
+ Event string `json:"event"`
+}
+
+func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
+ return &webPushControlMessagePayload{
+ Event: webPushExpiringEvent,
+ }
+}
+
+type webPushSubscription struct {
+ ID string
+ Endpoint string
+ Auth string
+ P256dh string
+ UserID string
+}
+
+func (w *webPushSubscription) Context() log.Context {
+ return map[string]any{
+ "web_push_subscription_id": w.ID,
+ "web_push_subscription_user_id": w.UserID,
+ "web_push_subscription_endpoint": w.Endpoint,
+ }
+}
+
+// https://developer.mozilla.org/en-US/docs/Web/Manifest
+type webManifestResponse struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ShortName string `json:"short_name"`
+ Scope string `json:"scope"`
+ StartURL string `json:"start_url"`
+ Display string `json:"display"`
+ BackgroundColor string `json:"background_color"`
+ ThemeColor string `json:"theme_color"`
+ Icons []*webManifestIcon `json:"icons"`
+}
+
+type webManifestIcon struct {
+ SRC string `json:"src"`
+ Sizes string `json:"sizes"`
+ Type string `json:"type"`
+}
diff --git a/server/webpush_store.go b/server/webpush_store.go
new file mode 100644
index 00000000..b2ab0d11
--- /dev/null
+++ b/server/webpush_store.go
@@ -0,0 +1,280 @@
+package server
+
+import (
+ "database/sql"
+ "errors"
+ "heckel.io/ntfy/util"
+ "net/netip"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3" // SQLite driver
+)
+
+const (
+ subscriptionIDPrefix = "wps_"
+ subscriptionIDLength = 10
+ subscriptionEndpointLimitPerSubscriberIP = 10
+)
+
+var (
+ errWebPushNoRows = errors.New("no rows found")
+ errWebPushTooManySubscriptions = errors.New("too many subscriptions")
+ errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
+)
+
+const (
+ createWebPushSubscriptionsTableQuery = `
+ BEGIN;
+ CREATE TABLE IF NOT EXISTS subscription (
+ id TEXT PRIMARY KEY,
+ endpoint TEXT NOT NULL,
+ key_auth TEXT NOT NULL,
+ key_p256dh TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ subscriber_ip TEXT NOT NULL,
+ updated_at INT NOT NULL,
+ warned_at INT NOT NULL DEFAULT 0
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
+ CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
+ CREATE TABLE IF NOT EXISTS subscription_topic (
+ subscription_id TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ PRIMARY KEY (subscription_id, topic),
+ FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
+ CREATE TABLE IF NOT EXISTS schemaVersion (
+ id INT PRIMARY KEY,
+ version INT NOT NULL
+ );
+ COMMIT;
+ `
+ builtinStartupQueries = `
+ PRAGMA foreign_keys = ON;
+ `
+
+ selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
+ selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
+ selectWebPushSubscriptionsForTopicQuery = `
+ SELECT id, endpoint, key_auth, key_p256dh, user_id
+ FROM subscription_topic st
+ JOIN subscription s ON s.id = st.subscription_id
+ WHERE st.topic = ?
+ ORDER BY endpoint
+ `
+ selectWebPushSubscriptionsExpiringSoonQuery = `
+ SELECT id, endpoint, key_auth, key_p256dh, user_id
+ FROM subscription
+ WHERE warned_at = 0 AND updated_at <= ?
+ `
+ insertWebPushSubscriptionQuery = `
+ INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (endpoint)
+ DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
+ `
+ updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
+ deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
+ deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
+ deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
+
+ insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
+ deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
+)
+
+// Schema management queries
+const (
+ currentWebPushSchemaVersion = 1
+ insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
+ selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
+)
+
+type webPushStore struct {
+ db *sql.DB
+}
+
+func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
+ db, err := sql.Open("sqlite3", filename)
+ if err != nil {
+ return nil, err
+ }
+ if err := setupWebPushDB(db); err != nil {
+ return nil, err
+ }
+ if err := runWebPushStartupQueries(db, startupQueries); err != nil {
+ return nil, err
+ }
+ return &webPushStore{
+ db: db,
+ }, nil
+}
+
+func setupWebPushDB(db *sql.DB) error {
+ // If 'schemaVersion' table does not exist, this must be a new database
+ rows, err := db.Query(selectWebPushSchemaVersionQuery)
+ if err != nil {
+ return setupNewWebPushDB(db)
+ }
+ return rows.Close()
+}
+
+func setupNewWebPushDB(db *sql.DB) error {
+ if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
+ return err
+ }
+ if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
+ return err
+ }
+ return nil
+}
+
+func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
+ if _, err := db.Exec(startupQueries); err != nil {
+ return err
+ }
+ if _, err := db.Exec(builtinStartupQueries); err != nil {
+ return err
+ }
+ return nil
+}
+
+// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
+// existing entries for a given endpoint.
+func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ // Read number of subscriptions for subscriber IP address
+ rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
+ if err != nil {
+ return err
+ }
+ defer rowsCount.Close()
+ var subscriptionCount int
+ if !rowsCount.Next() {
+ return errWebPushNoRows
+ }
+ if err := rowsCount.Scan(&subscriptionCount); err != nil {
+ return err
+ }
+ if err := rowsCount.Close(); err != nil {
+ return err
+ }
+ // Read existing subscription ID for endpoint (or create new ID)
+ rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ var subscriptionID string
+ if rows.Next() {
+ if err := rows.Scan(&subscriptionID); err != nil {
+ return err
+ }
+ } else {
+ if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
+ return errWebPushTooManySubscriptions
+ }
+ subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ // Insert or update subscription
+ updatedAt, warnedAt := time.Now().Unix(), 0
+ if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
+ return err
+ }
+ // Replace all subscription topics
+ if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
+ return err
+ }
+ for _, topic := range topics {
+ if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// SubscriptionsForTopic returns all subscriptions for the given topic
+func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
+ rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return c.subscriptionsFromRows(rows)
+}
+
+// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
+func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
+ rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return c.subscriptionsFromRows(rows)
+}
+
+// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
+func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, subscription := range subscriptions {
+ if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
+ subscriptions := make([]*webPushSubscription, 0)
+ for rows.Next() {
+ var id, endpoint, auth, p256dh, userID string
+ if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
+ return nil, err
+ }
+ subscriptions = append(subscriptions, &webPushSubscription{
+ ID: id,
+ Endpoint: endpoint,
+ Auth: auth,
+ P256dh: p256dh,
+ UserID: userID,
+ })
+ }
+ return subscriptions, nil
+}
+
+// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
+func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
+ _, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
+ return err
+}
+
+// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
+func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
+ if userID == "" {
+ return errWebPushUserIDCannotBeEmpty
+ }
+ _, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
+ return err
+}
+
+// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
+func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
+ _, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
+ return err
+}
+
+// Close closes the underlying database connection
+func (c *webPushStore) Close() error {
+ return c.db.Close()
+}
diff --git a/server/webpush_store_test.go b/server/webpush_store_test.go
new file mode 100644
index 00000000..ab5bc424
--- /dev/null
+++ b/server/webpush_store_test.go
@@ -0,0 +1,199 @@
+package server
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/require"
+ "net/netip"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ subs, err := webPush.SubscriptionsForTopic("test-topic")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
+ require.Equal(t, subs[0].P256dh, "p256dh-key")
+ require.Equal(t, subs[0].Auth, "auth-key")
+ require.Equal(t, subs[0].UserID, "u_1234")
+
+ subs2, err := webPush.SubscriptionsForTopic("mytopic")
+ require.Nil(t, err)
+ require.Len(t, subs2, 1)
+ require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
+}
+
+func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert 10 subscriptions with the same IP address
+ for i := 0; i < 10; i++ {
+ endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
+ require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+ }
+
+ // Another one for the same endpoint should be fine
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ // But with a different endpoint it should fail
+ require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
+
+ // But with a different IP address it should be fine again
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
+}
+
+func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics, and another with one topic
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
+
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 2)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+ require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
+
+ subs, err = webPush.SubscriptionsForTopic("topic2")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+
+ // Update the first subscription to have only one topic
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
+
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 2)
+ require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
+
+ subs, err = webPush.SubscriptionsForTopic("topic2")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // And remove it again
+ require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // And remove it again
+ require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+ require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
+}
+
+func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Mark them as warning sent
+ require.Nil(t, webPush.MarkExpiryWarningSent(subs))
+
+ rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
+ require.Nil(t, err)
+ defer rows.Close()
+ var endpoint string
+ require.True(t, rows.Next())
+ require.Nil(t, rows.Scan(&endpoint))
+ require.Nil(t, err)
+ require.Equal(t, testWebPushEndpoint, endpoint)
+ require.False(t, rows.Next())
+}
+
+func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Fake-mark them as soon-to-expire
+ _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
+ require.Nil(t, err)
+
+ // Should not be cleaned up yet
+ require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
+
+ // Run expiration
+ subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+ require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
+}
+
+func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
+ webPush := newTestWebPushStore(t)
+ defer webPush.Close()
+
+ // Insert subscription with two topics
+ require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
+ subs, err := webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 1)
+
+ // Fake-mark them as expired
+ _, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
+ require.Nil(t, err)
+
+ // Run expiration
+ require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
+
+ // List again, should be 0
+ subs, err = webPush.SubscriptionsForTopic("topic1")
+ require.Nil(t, err)
+ require.Len(t, subs, 0)
+}
+
+func newTestWebPushStore(t *testing.T) *webPushStore {
+ webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
+ require.Nil(t, err)
+ return webPush
+}
diff --git a/user/manager.go b/user/manager.go
index 00407ab3..87b385e6 100644
--- a/user/manager.go
+++ b/user/manager.go
@@ -126,6 +126,7 @@ const (
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
+
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
@@ -508,7 +509,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
// given user, if there are too many of them.
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) {
- token := util.RandomStringPrefix(tokenPrefix, tokenLength)
+ token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "+@" email addresses
tx, err := a.db.Begin()
if err != nil {
return nil, err
diff --git a/user/manager_test.go b/user/manager_test.go
index 5e01f497..85e3c428 100644
--- a/user/manager_test.go
+++ b/user/manager_test.go
@@ -183,6 +183,19 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
require.Equal(t, ErrUserNotFound, err)
}
+func TestManager_CreateToken_Only_Lower(t *testing.T) {
+ a := newTestManager(t, PermissionDenyAll)
+
+ // Create user, add reservations and token
+ require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
+ u, err := a.User("user")
+ require.Nil(t, err)
+
+ token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
+ require.Nil(t, err)
+ require.Equal(t, token.Value, strings.ToLower(token.Value))
+}
+
func TestManager_UserManagement(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
diff --git a/util/util.go b/util/util.go
index 84177d9f..4a63e22f 100644
--- a/util/util.go
+++ b/util/util.go
@@ -23,7 +23,8 @@ import (
)
const (
- randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ randomStringLowerCaseCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
)
var (
@@ -112,11 +113,20 @@ func RandomString(length int) string {
// RandomStringPrefix returns a random string with a given length, with a prefix
func RandomStringPrefix(prefix string, length int) string {
+ return randomStringPrefixWithCharset(prefix, length, randomStringCharset)
+}
+
+// RandomLowerStringPrefix returns a random lowercase-only string with a given length, with a prefix
+func RandomLowerStringPrefix(prefix string, length int) string {
+ return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
+}
+
+func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
defer randomMutex.Unlock()
b := make([]byte, length-len(prefix))
for i := range b {
- b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
+ b[i] = charset[random.Intn(len(charset))]
}
return prefix + string(b)
}
diff --git a/web/.eslintrc b/web/.eslintrc
index adf66130..a21221fc 100644
--- a/web/.eslintrc
+++ b/web/.eslintrc
@@ -33,5 +33,6 @@
"unnamedComponents": "arrow-function"
}
]
- }
+ },
+ "overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
}
diff --git a/web/index.html b/web/index.html
index c146e64d..462bbc1f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,11 +13,17 @@
+
+
+
@@ -35,6 +41,9 @@
+
+
+