Implement push subscription expiry

pull/751/head
nimbleghost 2023-06-02 14:45:05 +02:00
parent 47ad024ec7
commit 0f0074cbab
16 changed files with 272 additions and 102 deletions

View File

@ -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)

View File

@ -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
``` ```

View File

@ -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 | ✅ | ❌ | |

View File

@ -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,
} }
} }

View File

@ -29,6 +29,7 @@ const (
tagResetter = "resetter" tagResetter = "resetter"
tagWebsocket = "websocket" tagWebsocket = "websocket"
tagMatrix = "matrix" tagMatrix = "matrix"
tagWebPush = "web_push"
) )
var ( var (

View File

@ -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.

View File

@ -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

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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()
} }

View File

@ -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);
}
}
})() })()
); );
}); });

View File

@ -42,4 +42,5 @@ class Prefs {
} }
} }
export default new Prefs(getDb()); const prefs = new Prefs(getDb());
export default prefs;

View File

@ -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()) {

View File

@ -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 = () => {

View File

@ -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