Docs in server.yml, schemaVersion table, refactoring

pull/751/head
binwiederhier 2023-06-08 21:45:52 -04:00
parent d3ac976d05
commit 9d38aeb863
7 changed files with 74 additions and 37 deletions

View File

@ -38,15 +38,6 @@
# #
# firebase-key-file: <filename> # firebase-key-file: <filename>
# Enable web push
#
# Run "ntfy webpush keys" to generate the keys
#
# web-push-public-key:
# web-push-private-key:
# web-push-subscriptions-file:
# web-push-email-address:
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory. # If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
# This allows for service restarts without losing messages in support of the since= parameter. # This allows for service restarts without losing messages in support of the since= parameter.
# #
@ -153,6 +144,25 @@
# smtp-server-domain: # smtp-server-domain:
# smtp-server-addr-prefix: # 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, the user
# can enable background notifications. Once enabled by the user, ntfy will forward published messages to the push
# endpoint, which will then forward it to the browser.
#
# You must configure all settings 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-subscriptions-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-public-key:
# web-push-private-key:
# web-push-subscriptions-file:
# web-push-email-address:
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. # If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
# #
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 # - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586

View File

@ -15,9 +15,7 @@ func (s *Server) execManager() {
s.pruneTokens() s.pruneTokens()
s.pruneAttachments() s.pruneAttachments()
s.pruneMessages() s.pruneMessages()
if s.config.WebPushPublicKey != "" { s.pruneOrNotifyWebPushSubscriptions()
s.expireOrNotifyOldSubscriptions()
}
// Message count per topic // Message count per topic
var messagesCached int var messagesCached int

View File

@ -78,28 +78,39 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
// TODO this should return error // TODO this should return error
// TODO rate limiting // TODO rate limiting
func (s *Server) expireOrNotifyOldSubscriptions() { func (s *Server) pruneOrNotifyWebPushSubscriptions() {
if s.config.WebPushPublicKey == "" {
return
}
go func() {
if err := s.pruneOrNotifyWebPushSubscriptionsInternal(); err != nil {
log.Tag(tagWebPush).Err(err).Warn("Unable to prune or notify web push subscriptions")
}
}()
}
func (s *Server) pruneOrNotifyWebPushSubscriptionsInternal() error {
subscriptions, err := s.webPush.ExpireAndGetExpiringSubscriptions(s.config.WebPushExpiryWarningDuration, s.config.WebPushExpiryDuration) subscriptions, err := s.webPush.ExpireAndGetExpiringSubscriptions(s.config.WebPushExpiryWarningDuration, s.config.WebPushExpiryDuration)
if err != nil { if err != nil {
log.Tag(tagWebPush).Err(err).Warn("Unable to publish expiry imminent warning") log.Tag(tagWebPush).Err(err).Warn("Unable to publish expiry imminent warning")
return return err
} else if len(subscriptions) == 0 { } else if len(subscriptions) == 0 {
return return nil
} }
payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload()) payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload())
if err != nil { if err != nil {
log.Tag(tagWebPush).Err(err).Warn("Unable to marshal expiring payload") log.Tag(tagWebPush).Err(err).Warn("Unable to marshal expiring payload")
return return err
} }
go func() {
for _, subscription := range subscriptions { for _, subscription := range subscriptions {
ctx := log.Context{"endpoint": subscription.BrowserSubscription.Endpoint} ctx := log.Context{"endpoint": subscription.BrowserSubscription.Endpoint}
if err := s.sendWebPushNotification(payload, &subscription, &ctx); err != nil { if err := s.sendWebPushNotification(payload, &subscription, &ctx); err != nil {
log.Tag(tagWebPush).Err(err).Fields(ctx).Warn("Unable to publish expiry imminent warning") log.Tag(tagWebPush).Err(err).Fields(ctx).Warn("Unable to publish expiry imminent warning")
return err
} }
} }
}()
log.Tag(tagWebPush).Debug("Expiring old subscriptions and published %d expiry imminent warnings", len(subscriptions)) log.Tag(tagWebPush).Debug("Expiring old subscriptions and published %d expiry imminent warnings", len(subscriptions))
return nil
} }
func (s *Server) sendWebPushNotification(message []byte, sub *webPushSubscription, ctx *log.Context) error { func (s *Server) sendWebPushNotification(message []byte, sub *webPushSubscription, ctx *log.Context) error {

View File

@ -149,7 +149,7 @@ func TestServer_WebPush_Publish(t *testing.T) {
}) })
} }
func TestServer_WebPush_PublishExpire(t *testing.T) { func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t)) s := newTestServer(t, newTestConfigWithWebPush(t))
var received atomic.Bool var received atomic.Bool
@ -201,7 +201,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
_, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')") _, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')")
require.Nil(t, err) require.Nil(t, err)
s.expireOrNotifyOldSubscriptions() s.pruneOrNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 1) requireSubscriptionCount(t, s, "test-topic", 1)
waitFor(t, func() bool { waitFor(t, func() bool {
@ -211,8 +211,12 @@ func TestServer_WebPush_Expiry(t *testing.T) {
_, err = s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-8 days')") _, err = s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-8 days')")
require.Nil(t, err) require.Nil(t, err)
s.expireOrNotifyOldSubscriptions() s.pruneOrNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 0) 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 { func payloadForTopics(t *testing.T, topics []string, endpoint string) string {
@ -246,6 +250,5 @@ func addSubscription(t *testing.T, s *Server, topic string, url string) {
func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) { func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) {
subs, err := s.webPush.SubscriptionsForTopic("test-topic") subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err) require.Nil(t, err)
require.Len(t, subs, expectedLength) require.Len(t, subs, expectedLength)
} }

View File

@ -22,11 +22,16 @@ const (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
warning_sent BOOLEAN DEFAULT FALSE warning_sent BOOLEAN DEFAULT FALSE
); );
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
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);
CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON subscriptions (topic, endpoint); CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON subscriptions (topic, endpoint);
COMMIT; COMMIT;
` `
insertWebPushSubscriptionQuery = ` insertWebPushSubscriptionQuery = `
INSERT OR REPLACE INTO subscriptions (topic, user_id, endpoint, key_auth, key_p256dh) INSERT OR REPLACE INTO subscriptions (topic, user_id, endpoint, key_auth, key_p256dh)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@ -39,8 +44,13 @@ const (
selectWebPushSubscriptionsExpiringSoonQuery = `SELECT DISTINCT endpoint, key_auth, key_p256dh FROM subscriptions WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)` 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', ?)` updateWarningSentQuery = `UPDATE subscriptions SET warning_sent = true WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)`
)
selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM subscriptions` // Schema management queries
const (
currentWebPushSchemaVersion = 1
insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
) )
type webPushStore struct { type webPushStore struct {
@ -52,7 +62,7 @@ func newWebPushStore(filename string) (*webPushStore, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSubscriptionsDB(db); err != nil { if err := setupWebPushDB(db); err != nil {
return nil, err return nil, err
} }
return &webPushStore{ return &webPushStore{
@ -60,33 +70,38 @@ func newWebPushStore(filename string) (*webPushStore, error) {
}, nil }, nil
} }
func setupSubscriptionsDB(db *sql.DB) error { func setupWebPushDB(db *sql.DB) error {
// If 'subscriptions' table does not exist, this must be a new database // If 'schemaVersion' table does not exist, this must be a new database
rows, err := db.Query(selectWebPushSubscriptionsCountQuery) rows, err := db.Query(selectWebPushSchemaVersionQuery)
if err != nil { if err != nil {
return setupNewSubscriptionsDB(db) return setupNewWebPushDB(db)
} }
return rows.Close() return rows.Close()
} }
func setupNewSubscriptionsDB(db *sql.DB) error { func setupNewWebPushDB(db *sql.DB) error {
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil { if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
return err return err
} }
if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
return err
}
return nil return nil
} }
// UpdateSubscriptions updates the subscriptions for the given topics and user ID. It always first deletes all
// existing entries for a given endpoint.
func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error { func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error {
tx, err := c.db.Begin() tx, err := c.db.Begin()
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if err = c.RemoveByEndpoint(subscription.Endpoint); err != nil { if _, err := tx.Exec(deleteWebPushSubscriptionByEndpointQuery, subscription.Endpoint); err != nil {
return err return err
} }
for _, topic := range topics { for _, topic := range topics {
if err := c.AddSubscription(topic, userID, subscription); err != nil { if _, err = tx.Exec(insertWebPushSubscriptionQuery, topic, userID, subscription.Endpoint, subscription.Keys.Auth, subscription.Keys.P256dh); err != nil {
return err return err
} }
} }