Implement push subscription expiry
parent
47ad024ec7
commit
0f0074cbab
|
@ -99,6 +99,8 @@ var flagsServe = append(
|
||||||
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-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-subscriptions-file", Aliases: []string{"web_push_subscriptions_file"}, EnvVars: []string{"NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE"}, Usage: "file used to store web push subscriptions"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-subscriptions-file", Aliases: []string{"web_push_subscriptions_file"}, EnvVars: []string{"NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE"}, Usage: "file used to store web push subscriptions"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
|
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.NewDurationFlag(&cli.DurationFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: server.DefaultWebPushExpiryWarningDuration, Usage: "duration after last update to send a warning notification"}),
|
||||||
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: server.DefaultWebPushExpiryDuration, Usage: "duration after last update to expire subscription"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdServe = &cli.Command{
|
var cmdServe = &cli.Command{
|
||||||
|
@ -138,6 +140,8 @@ func execServe(c *cli.Context) error {
|
||||||
webPushPrivateKey := c.String("web-push-private-key")
|
webPushPrivateKey := c.String("web-push-private-key")
|
||||||
webPushPublicKey := c.String("web-push-public-key")
|
webPushPublicKey := c.String("web-push-public-key")
|
||||||
webPushSubscriptionsFile := c.String("web-push-subscriptions-file")
|
webPushSubscriptionsFile := c.String("web-push-subscriptions-file")
|
||||||
|
webPushExpiryWarningDuration := c.Duration("web-push-expiry-warning-duration")
|
||||||
|
webPushExpiryDuration := c.Duration("web-push-expiry-duration")
|
||||||
webPushEmailAddress := c.String("web-push-email-address")
|
webPushEmailAddress := c.String("web-push-email-address")
|
||||||
cacheFile := c.String("cache-file")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDuration := c.Duration("cache-duration")
|
||||||
|
@ -197,6 +201,8 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-subscriptions-file, web-push-email-address, and base-url should be set. run 'ntfy web-push generate-keys' to generate keys")
|
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-subscriptions-file, web-push-email-address, and base-url should be set. run 'ntfy web-push generate-keys' to generate keys")
|
||||||
} else if keepaliveInterval < 5*time.Second {
|
} else if keepaliveInterval < 5*time.Second {
|
||||||
return errors.New("keepalive interval cannot be lower than five seconds")
|
return errors.New("keepalive interval cannot be lower than five seconds")
|
||||||
|
} else if webPushEnabled && (webPushExpiryWarningDuration < 24*time.Hour || (webPushExpiryDuration-webPushExpiryWarningDuration < 24*time.Hour)) {
|
||||||
|
return errors.New("web push expiry warning duration must be at least 1 day, expire duration must be at least 1 day later")
|
||||||
} else if managerInterval < 5*time.Second {
|
} else if managerInterval < 5*time.Second {
|
||||||
return errors.New("manager interval cannot be lower than five seconds")
|
return errors.New("manager interval cannot be lower than five seconds")
|
||||||
} else if cacheDuration > 0 && cacheDuration < managerInterval {
|
} else if cacheDuration > 0 && cacheDuration < managerInterval {
|
||||||
|
@ -364,6 +370,8 @@ func execServe(c *cli.Context) error {
|
||||||
conf.WebPushPublicKey = webPushPublicKey
|
conf.WebPushPublicKey = webPushPublicKey
|
||||||
conf.WebPushSubscriptionsFile = webPushSubscriptionsFile
|
conf.WebPushSubscriptionsFile = webPushSubscriptionsFile
|
||||||
conf.WebPushEmailAddress = webPushEmailAddress
|
conf.WebPushEmailAddress = webPushEmailAddress
|
||||||
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
|
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
go sigHandlerConfigReload(config)
|
go sigHandlerConfigReload(config)
|
||||||
|
|
|
@ -827,6 +827,11 @@ web-push-email-address: sysadmin@example.com
|
||||||
|
|
||||||
# don't forget to set the required base-url for web push notifications
|
# don't forget to set the required base-url for web push notifications
|
||||||
base-url: https://ntfy.example.com
|
base-url: https://ntfy.example.com
|
||||||
|
|
||||||
|
# you can also set custom expiry and warning durations
|
||||||
|
# the minimum is 1 day for warning, and 1 day after warning for expiry
|
||||||
|
web-push-expiry-warning-duration: 168h
|
||||||
|
web-push-expiry-duration: 192h
|
||||||
```
|
```
|
||||||
|
|
||||||
The `web-push-subscriptions-file` is used to store the push subscriptions. Subscriptions do not ever expire automatically, unless the push
|
The `web-push-subscriptions-file` is used to store the push subscriptions. Subscriptions do not ever expire automatically, unless the push
|
||||||
|
@ -1339,6 +1344,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push generate-keys` to generate |
|
| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push generate-keys` to generate |
|
||||||
| `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file |
|
| `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file |
|
||||||
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
||||||
|
| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 1 week | Web Push: Time before expiry warning is sent (min 1 day) |
|
||||||
|
| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 1 week + 1 day | Web Push: Time before subscription is expired (min 1 day after warning) |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
@ -1436,5 +1443,8 @@ OPTIONS:
|
||||||
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
|
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
|
||||||
--web-push-subscriptions-file value, --web_push_subscriptions_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE]
|
--web-push-subscriptions-file value, --web_push_subscriptions_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE]
|
||||||
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
|
--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-expiry-warning-duration value, --web_push_expiry_warning_duration value duration after last update to send a warning notification (default: 168h0m0s) [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
|
||||||
|
--web-push-expiry-duration value, --web_push_expiry_duration value duration after last update to expire subscription (default: 192h0m0s) [$NTFY_WEB_PUSH_EXPIRY_DURATION]
|
||||||
--help, -h show help
|
--help, -h show help
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -2,15 +2,9 @@
|
||||||
|
|
||||||
You can use the Web UI to subscribe to topics as well. Simply type in the topic name and click the *Subscribe* button.
|
You can use the Web UI to subscribe to topics as well. Simply type in the topic name and click the *Subscribe* button.
|
||||||
|
|
||||||
While subscribing, you have the option to enable desktop notifications, as well as background notifications. When you
|
While subscribing, you have the option to enable background notifications on supported browsers.
|
||||||
enable them for the first time, you will be prompted to allow notifications on your browser.
|
|
||||||
|
|
||||||
- **Sound only**
|
- If background notifications are off:
|
||||||
|
|
||||||
If you don't enable browser notifications, a sound will play when a new notification comes in, and the tab title
|
|
||||||
will show the number of new notifications.
|
|
||||||
|
|
||||||
- **Browser Notifications**
|
|
||||||
|
|
||||||
This requires an active ntfy tab to be open to receive notifications. These are typically instantaneous, and will
|
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
|
appear as a system notification. If you don't see these, check that your browser is allowed to show notifications
|
||||||
|
@ -19,7 +13,7 @@ enable them for the first time, you will be prompted to allow notifications on y
|
||||||
If you don't want to enable background notifications, pinning the ntfy tab on your browser is a good solution to leave
|
If you don't want to enable background notifications, pinning the ntfy tab on your browser is a good solution to leave
|
||||||
it running.
|
it running.
|
||||||
|
|
||||||
- **Background Notifications**
|
- 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
|
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.
|
cases you may need to keep your browser open.
|
||||||
|
@ -27,6 +21,9 @@ enable them for the first time, you will be prompted to allow notifications on y
|
||||||
Background notifications are only supported on the same server hosting the web app. You cannot use another server,
|
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.
|
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 |
|
| Browser | Platform | Browser Running | Browser Not Running | Restrictions |
|
||||||
| ------- | -------- | --------------- | ------------------- | ------------------------------------------------------- |
|
| ------- | -------- | --------------- | ------------------- | ------------------------------------------------------- |
|
||||||
| Chrome | Desktop | ✅ | ❌ | |
|
| Chrome | Desktop | ✅ | ❌ | |
|
||||||
|
|
|
@ -23,6 +23,12 @@ const (
|
||||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
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 = DefaultWebPushExpiryWarningDuration + 24*time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// Defines all global and per-visitor limits
|
// Defines all global and per-visitor limits
|
||||||
// - message size limit: the max number of bytes for a message
|
// - message size limit: the max number of bytes for a message
|
||||||
// - total topic limit: max number of topics overall
|
// - total topic limit: max number of topics overall
|
||||||
|
@ -152,6 +158,8 @@ type Config struct {
|
||||||
WebPushPublicKey string
|
WebPushPublicKey string
|
||||||
WebPushSubscriptionsFile string
|
WebPushSubscriptionsFile string
|
||||||
WebPushEmailAddress string
|
WebPushEmailAddress string
|
||||||
|
WebPushExpiryDuration time.Duration
|
||||||
|
WebPushExpiryWarningDuration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
|
@ -238,5 +246,7 @@ func NewConfig() *Config {
|
||||||
WebPushPublicKey: "",
|
WebPushPublicKey: "",
|
||||||
WebPushSubscriptionsFile: "",
|
WebPushSubscriptionsFile: "",
|
||||||
WebPushEmailAddress: "",
|
WebPushEmailAddress: "",
|
||||||
|
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||||
|
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ const (
|
||||||
tagResetter = "resetter"
|
tagResetter = "resetter"
|
||||||
tagWebsocket = "websocket"
|
tagWebsocket = "websocket"
|
||||||
tagMatrix = "matrix"
|
tagMatrix = "matrix"
|
||||||
|
tagWebPush = "web_push"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -47,6 +47,8 @@
|
||||||
# web-push-private-key:
|
# web-push-private-key:
|
||||||
# web-push-subscriptions-file:
|
# web-push-subscriptions-file:
|
||||||
# web-push-email-address:
|
# web-push-email-address:
|
||||||
|
# web-push-expiry-warning-duration: 168h
|
||||||
|
# web-push-expiry-duration: 192h
|
||||||
|
|
||||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||||
|
|
|
@ -15,6 +15,9 @@ func (s *Server) execManager() {
|
||||||
s.pruneTokens()
|
s.pruneTokens()
|
||||||
s.pruneAttachments()
|
s.pruneAttachments()
|
||||||
s.pruneMessages()
|
s.pruneMessages()
|
||||||
|
if s.config.WebPushEnabled {
|
||||||
|
s.expireOrNotifyOldSubscriptions()
|
||||||
|
}
|
||||||
|
|
||||||
// Message count per topic
|
// Message count per topic
|
||||||
var messagesCached int
|
var messagesCached int
|
||||||
|
|
|
@ -43,6 +43,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||||
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
|
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logvm(v, m).Err(err).Warn("Unable to publish web push messages")
|
logvm(v, m).Err(err).Warn("Unable to publish web push messages")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,42 +51,61 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||||
go func(i int, sub webPushSubscription) {
|
go func(i int, sub webPushSubscription) {
|
||||||
ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint, "username": sub.UserID, "topic": m.Topic, "message_id": m.ID}
|
ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint, "username": sub.UserID, "topic": m.Topic, "message_id": m.ID}
|
||||||
|
|
||||||
payload := &webPushPayload{
|
s.sendWebPushNotification(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), *m), &sub, &ctx)
|
||||||
SubscriptionID: fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic),
|
|
||||||
Message: *m,
|
|
||||||
}
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{
|
|
||||||
Subscriber: s.config.WebPushEmailAddress,
|
|
||||||
VAPIDPublicKey: s.config.WebPushPublicKey,
|
|
||||||
VAPIDPrivateKey: s.config.WebPushPrivateKey,
|
|
||||||
// Deliverability on iOS isn't great with lower urgency values,
|
|
||||||
// and thus we can't really map lower ntfy priorities to lower urgency values
|
|
||||||
Urgency: webpush.UrgencyHigh,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
|
|
||||||
if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil {
|
|
||||||
logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// May want to handle at least 429 differently, but for now treat all errors the same
|
|
||||||
if !(200 <= resp.StatusCode && resp.StatusCode <= 299) {
|
|
||||||
logvm(v, m).Fields(ctx).Field("response", resp).Debug("Unable to publish web push message")
|
|
||||||
if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil {
|
|
||||||
logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}(i, xi)
|
}(i, xi)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) expireOrNotifyOldSubscriptions() {
|
||||||
|
subscriptions, err := s.webPush.ExpireAndGetExpiringSubscriptions(s.config.WebPushExpiryWarningDuration, s.config.WebPushExpiryDuration)
|
||||||
|
if err != nil {
|
||||||
|
log.Tag(tagWebPush).Err(err).Warn("Unable to publish expiry imminent warning")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, xi := range subscriptions {
|
||||||
|
go func(i int, sub webPushSubscription) {
|
||||||
|
ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint}
|
||||||
|
|
||||||
|
s.sendWebPushNotification(newWebPushSubscriptionExpiringPayload(), &sub, &ctx)
|
||||||
|
}(i, xi)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendWebPushNotification(payload any, sub *webPushSubscription, ctx *log.Context) {
|
||||||
|
jsonPayload, err := json.Marshal(payload)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Tag(tagWebPush).Err(err).Fields(*ctx).Debug("Unable to publish web push message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{
|
||||||
|
Subscriber: s.config.WebPushEmailAddress,
|
||||||
|
VAPIDPublicKey: s.config.WebPushPublicKey,
|
||||||
|
VAPIDPrivateKey: s.config.WebPushPrivateKey,
|
||||||
|
// Deliverability on iOS isn't great with lower urgency values,
|
||||||
|
// and thus we can't really map lower ntfy priorities to lower urgency values
|
||||||
|
Urgency: webpush.UrgencyHigh,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Tag(tagWebPush).Err(err).Fields(*ctx).Debug("Unable to publish web push message")
|
||||||
|
if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil {
|
||||||
|
log.Tag(tagWebPush).Err(err).Fields(*ctx).Warn("Unable to expire subscription")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// May want to handle at least 429 differently, but for now treat all errors the same
|
||||||
|
if !(200 <= resp.StatusCode && resp.StatusCode <= 299) {
|
||||||
|
log.Tag(tagWebPush).Fields(*ctx).Field("response", resp).Debug("Unable to publish web push message")
|
||||||
|
if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil {
|
||||||
|
log.Tag(tagWebPush).Err(err).Fields(*ctx).Warn("Unable to expire subscription")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -157,8 +157,42 @@ func TestServer_WebPush_PublishExpire(t *testing.T) {
|
||||||
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_WebPush_Expiry(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||||
|
|
||||||
|
var received atomic.Bool
|
||||||
|
|
||||||
|
upstreamServer := 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 upstreamServer.Close()
|
||||||
|
|
||||||
|
addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive")
|
||||||
|
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||||
|
|
||||||
|
_, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
s.expireOrNotifyOldSubscriptions()
|
||||||
|
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||||
|
|
||||||
|
waitFor(t, func() bool {
|
||||||
|
return received.Load()
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-8 days')")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
s.expireOrNotifyOldSubscriptions()
|
||||||
|
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||||
|
}
|
||||||
|
|
||||||
func payloadForTopics(t *testing.T, topics []string) string {
|
func payloadForTopics(t *testing.T, topics []string) string {
|
||||||
topicsJson, err := json.Marshal(topics)
|
topicsJSON, err := json.Marshal(topics)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
return fmt.Sprintf(`{
|
return fmt.Sprintf(`{
|
||||||
|
@ -170,7 +204,7 @@ func payloadForTopics(t *testing.T, topics []string) string {
|
||||||
"auth": "auth-key"
|
"auth": "auth-key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`, topicsJson)
|
}`, topicsJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSubscription(t *testing.T, s *Server, topic string, url string) {
|
func addSubscription(t *testing.T, s *Server, topic string, url string) {
|
||||||
|
|
|
@ -468,10 +468,29 @@ type apiStripeSubscriptionDeletedEvent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type webPushPayload struct {
|
type webPushPayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
SubscriptionID string `json:"subscription_id"`
|
SubscriptionID string `json:"subscription_id"`
|
||||||
Message message `json:"message"`
|
Message message `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newWebPushPayload(subscriptionID string, message message) webPushPayload {
|
||||||
|
return webPushPayload{
|
||||||
|
Event: "message",
|
||||||
|
SubscriptionID: subscriptionID,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type webPushControlMessagePayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebPushSubscriptionExpiringPayload() webPushControlMessagePayload {
|
||||||
|
return webPushControlMessagePayload{
|
||||||
|
Event: "subscription_expiring",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type webPushSubscription struct {
|
type webPushSubscription struct {
|
||||||
BrowserSubscription webpush.Subscription
|
BrowserSubscription webpush.Subscription
|
||||||
UserID string
|
UserID string
|
||||||
|
|
|
@ -3,6 +3,7 @@ package server
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/SherClockHolmes/webpush-go"
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
@ -18,7 +19,8 @@ const (
|
||||||
endpoint TEXT NOT NULL,
|
endpoint TEXT NOT NULL,
|
||||||
key_auth TEXT NOT NULL,
|
key_auth TEXT NOT NULL,
|
||||||
key_p256dh TEXT NOT NULL,
|
key_p256dh TEXT NOT NULL,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
warning_sent BOOLEAN DEFAULT FALSE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON subscriptions (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON subscriptions (topic);
|
||||||
CREATE INDEX IF NOT EXISTS idx_endpoint ON subscriptions (endpoint);
|
CREATE INDEX IF NOT EXISTS idx_endpoint ON subscriptions (endpoint);
|
||||||
|
@ -32,8 +34,12 @@ const (
|
||||||
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscriptions WHERE endpoint = ?`
|
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscriptions WHERE endpoint = ?`
|
||||||
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscriptions WHERE user_id = ?`
|
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscriptions WHERE user_id = ?`
|
||||||
deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM subscriptions WHERE topic = ? AND endpoint = ?`
|
deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM subscriptions WHERE topic = ? AND endpoint = ?`
|
||||||
|
deleteWebPushSubscriptionsByAgeQuery = `DELETE FROM subscriptions WHERE warning_sent = 1 AND updated_at <= datetime('now', ?)`
|
||||||
|
|
||||||
selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, user_id FROM subscriptions WHERE topic = ?`
|
selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, user_id FROM subscriptions WHERE topic = ?`
|
||||||
|
selectWebPushSubscriptionsExpiringSoonQuery = `SELECT DISTINCT endpoint, key_auth, key_p256dh FROM subscriptions WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)`
|
||||||
|
|
||||||
|
updateWarningSentQuery = `UPDATE subscriptions SET warning_sent = true WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)`
|
||||||
|
|
||||||
selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM subscriptions`
|
selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM subscriptions`
|
||||||
)
|
)
|
||||||
|
@ -72,7 +78,6 @@ func setupNewSubscriptionsDB(db *sql.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error {
|
func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error {
|
||||||
fmt.Printf("AAA")
|
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -121,6 +126,49 @@ func (c *webPushStore) SubscriptionsForTopic(topic string) (subscriptions []webP
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *webPushStore) ExpireAndGetExpiringSubscriptions(warningDuration time.Duration, expiryDuration time.Duration) (subscriptions []webPushSubscription, err error) {
|
||||||
|
tx, err := c.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.Exec(deleteWebPushSubscriptionsByAgeQuery, fmt.Sprintf("-%.2f seconds", expiryDuration.Seconds()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(selectWebPushSubscriptionsExpiringSoonQuery, fmt.Sprintf("-%.2f seconds", warningDuration.Seconds()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var data []webPushSubscription
|
||||||
|
for rows.Next() {
|
||||||
|
i := webPushSubscription{}
|
||||||
|
err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh)
|
||||||
|
fmt.Printf("%v+", i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// also set warning as sent
|
||||||
|
_, err = tx.Exec(updateWarningSentQuery, fmt.Sprintf("-%.2f seconds", warningDuration.Seconds()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *webPushStore) RemoveByEndpoint(endpoint string) error {
|
func (c *webPushStore) RemoveByEndpoint(endpoint string) error {
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
deleteWebPushSubscriptionByEndpointQuery,
|
deleteWebPushSubscriptionByEndpointQuery,
|
||||||
|
@ -136,6 +184,7 @@ func (c *webPushStore) RemoveByUserID(userID string) error {
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *webPushStore) Close() error {
|
func (c *webPushStore) Close() error {
|
||||||
return c.db.Close()
|
return c.db.Close()
|
||||||
}
|
}
|
||||||
|
|
112
web/public/sw.js
112
web/public/sw.js
|
@ -32,35 +32,50 @@ self.addEventListener("push", (event) => {
|
||||||
const data = event.data.json();
|
const data = event.data.json();
|
||||||
console.log("[ServiceWorker] Received Web Push Event", { event, data });
|
console.log("[ServiceWorker] Received Web Push Event", { event, data });
|
||||||
|
|
||||||
const { subscription_id: subscriptionId, message } = data;
|
|
||||||
broadcastChannel.postMessage(message);
|
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const db = await getDbAsync();
|
if (data.event === "subscription_expiring") {
|
||||||
|
await self.registration.showNotification("Notifications will be paused", {
|
||||||
await Promise.all([
|
body: "Open ntfy to continue receiving notifications",
|
||||||
(async () => {
|
|
||||||
await db.notifications.add({
|
|
||||||
...message,
|
|
||||||
subscriptionId,
|
|
||||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
|
||||||
new: 1,
|
|
||||||
});
|
|
||||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
|
||||||
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
|
|
||||||
self.navigator.setAppBadge?.(badgeCount);
|
|
||||||
})(),
|
|
||||||
db.subscriptions.update(subscriptionId, {
|
|
||||||
last: message.id,
|
|
||||||
}),
|
|
||||||
self.registration.showNotification(formatTitleWithDefault(message, message.topic), {
|
|
||||||
tag: subscriptionId,
|
|
||||||
body: formatMessage(message),
|
|
||||||
icon: "/static/images/ntfy.png",
|
icon: "/static/images/ntfy.png",
|
||||||
data,
|
data,
|
||||||
}),
|
});
|
||||||
]);
|
} else if (data.event === "message") {
|
||||||
|
const { subscription_id: subscriptionId, message } = data;
|
||||||
|
broadcastChannel.postMessage(message);
|
||||||
|
|
||||||
|
const db = await getDbAsync();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
await db.notifications.add({
|
||||||
|
...message,
|
||||||
|
subscriptionId,
|
||||||
|
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
|
new: 1,
|
||||||
|
});
|
||||||
|
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||||
|
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
|
||||||
|
self.navigator.setAppBadge?.(badgeCount);
|
||||||
|
})(),
|
||||||
|
db.subscriptions.update(subscriptionId, {
|
||||||
|
last: message.id,
|
||||||
|
}),
|
||||||
|
self.registration.showNotification(formatTitleWithDefault(message, message.topic), {
|
||||||
|
tag: subscriptionId,
|
||||||
|
body: formatMessage(message),
|
||||||
|
icon: "/static/images/ntfy.png",
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// We can't ignore the push, since permission can be revoked by the browser
|
||||||
|
await self.registration.showNotification("Unknown notification received from server", {
|
||||||
|
body: "You may need to update ntfy by opening the web app",
|
||||||
|
icon: "/static/images/ntfy.png",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -68,33 +83,38 @@ self.addEventListener("push", (event) => {
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
const { message } = event.notification.data;
|
|
||||||
|
|
||||||
if (message.click) {
|
|
||||||
self.clients.openWindow(message.click);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootUrl = new URL(self.location.origin);
|
|
||||||
const topicUrl = new URL(message.topic, self.location.origin);
|
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const clients = await self.clients.matchAll({ type: "window" });
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
|
||||||
const topicClient = clients.find((client) => client.url === topicUrl.toString());
|
const rootUrl = new URL(self.location.origin);
|
||||||
if (topicClient) {
|
|
||||||
topicClient.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
||||||
if (rootClient) {
|
|
||||||
rootClient.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.clients.openWindow(topicUrl);
|
if (event.notification.data.event !== "message") {
|
||||||
|
if (rootClient) {
|
||||||
|
rootClient.focus();
|
||||||
|
} else {
|
||||||
|
self.clients.openWindow(rootUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { message } = event.notification.data;
|
||||||
|
|
||||||
|
if (message.click) {
|
||||||
|
self.clients.openWindow(message.click);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicUrl = new URL(message.topic, self.location.origin);
|
||||||
|
const topicClient = clients.find((client) => client.url === topicUrl.toString());
|
||||||
|
|
||||||
|
if (topicClient) {
|
||||||
|
topicClient.focus();
|
||||||
|
} else if (rootClient) {
|
||||||
|
rootClient.focus();
|
||||||
|
} else {
|
||||||
|
self.clients.openWindow(topicUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -42,4 +42,5 @@ class Prefs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Prefs(getDb());
|
const prefs = new Prefs(getDb());
|
||||||
|
export default prefs;
|
||||||
|
|
|
@ -57,7 +57,6 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||||
import { ProChip } from "./SubscriptionPopup";
|
import { ProChip } from "./SubscriptionPopup";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
|
|
|
@ -58,9 +58,7 @@ const App = () => {
|
||||||
const updateTitle = (newNotificationsCount) => {
|
const updateTitle = (newNotificationsCount) => {
|
||||||
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||||
|
|
||||||
if ("setAppBadge" in window.navigator) {
|
window.navigator.setAppBadge?.(newNotificationsCount);
|
||||||
window.navigator.setAppBadge(newNotificationsCount);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
|
|
@ -10,7 +10,6 @@ import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import { webPushRefreshWorker, useWebPushUpdateWorker } from "../app/WebPushWorker";
|
import { webPushRefreshWorker, useWebPushUpdateWorker } from "../app/WebPushWorker";
|
||||||
import notifier from "../app/Notifier";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||||
|
|
Loading…
Reference in New Issue