Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PWA manifest, service worker, and web push #751

Merged
merged 82 commits into from
Jun 24, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
ff5c854
Add PWA, service worker and Web Push
nimbleghost May 24, 2023
a9fef38
Add web push tests
nimbleghost May 29, 2023
f94bb1a
Improve web push docs
nimbleghost May 29, 2023
7b23158
Cosmetic changes
binwiederhier May 30, 2023
7f3e4b5
Move stuff to server_web_push.go
binwiederhier May 30, 2023
9e0687e
Random tiny changes
binwiederhier May 30, 2023
e8139ad
Move web-push-config endpoint to config.js
binwiederhier May 30, 2023
20c7650
server.yml update
binwiederhier May 30, 2023
44913c1
Replace if err-nil-Fatal check with require.Nil
nimbleghost May 31, 2023
4648f83
Format emojis in the service worker directly
nimbleghost May 31, 2023
0c25425
Use readJSONWithLimit for web push sub/unsub
nimbleghost May 31, 2023
7aa3d8f
Hide web push toggles if disabled on server
nimbleghost May 31, 2023
4944e3a
Remove webPushEndpoint from indexeddb
nimbleghost May 31, 2023
47ad024
Simplify web push UX and updates
nimbleghost Jun 2, 2023
0f0074c
Implement push subscription expiry
nimbleghost Jun 2, 2023
46f34ca
Add push service allowlist and topic limit
nimbleghost Jun 2, 2023
03aa67e
Remove `webPushDefaultEnabled`
nimbleghost Jun 7, 2023
18edff9
Add TODO comment about Safari 17 PWA
nimbleghost Jun 7, 2023
f3db0e0
Add release notes
nimbleghost Jun 7, 2023
a8db08c
Use attachment URL for image & add timestamp
nimbleghost Jun 7, 2023
46798ac
Make web push toggle global
nimbleghost Jun 8, 2023
2f5acee
Call pushManager.subscribe only if enabled
nimbleghost Jun 8, 2023
75a4b5b
Small refactor
binwiederhier Jun 8, 2023
4ce6fdc
Implement http actions in service worker
nimbleghost Jun 8, 2023
d3ac976
Remove web-push-(enabled|duration*), change endpoint, other cosmetic …
binwiederhier Jun 8, 2023
9d38aeb
Docs in server.yml, schemaVersion table, refactoring
binwiederhier Jun 9, 2023
966ffe1
More refactor
binwiederhier Jun 9, 2023
9e4eafe
Format
nimbleghost Jun 9, 2023
4704b2a
Set default TTL for web push to the cache duration
nimbleghost Jun 9, 2023
2e8292a
No real changes, just renames
binwiederhier Jun 9, 2023
1abcc88
Add subscription_topic table, change updated_at type to INT, split ex…
binwiederhier Jun 10, 2023
9d5556c
Rename things, add comments
binwiederhier Jun 11, 2023
eb22054
Change wording in prefs based on setting
binwiederhier Jun 11, 2023
58992fc
Make DELETE endpoint, add different UI description
binwiederhier Jun 11, 2023
4e44b03
Merge branch 'main' of github.com:binwiederhier/ntfy into pwa
binwiederhier Jun 12, 2023
a8def0a
Make allowed endpoints a list of patterns
binwiederhier Jun 13, 2023
2d0c043
Derp
binwiederhier Jun 13, 2023
9e19183
Merge branch 'main' into pwa
binwiederhier Jun 13, 2023
8ccfa5c
Fix session replica behaviour (merge with session)
nimbleghost Jun 13, 2023
390d42c
Format & fix lint
nimbleghost Jun 13, 2023
cf050cc
Merge branch 'pwa' of github.com:nimbleghost/ntfy into pwa
binwiederhier Jun 14, 2023
6b38499
Revert alert text and button, and warning
binwiederhier Jun 14, 2023
790fd43
Tiny changes
binwiederhier Jun 14, 2023
7083ed9
Move websocketSubscriptions to useConnectionListeners
binwiederhier Jun 14, 2023
67b9d2e
Add missing await
nimbleghost Jun 14, 2023
e2120bc
Improve WebPushEnabled conditional display
nimbleghost Jun 14, 2023
67948d0
Remove stray console.log
nimbleghost Jun 14, 2023
aeb6073
Wording
binwiederhier Jun 14, 2023
ad36f5d
Merge branch 'main' into pwa
binwiederhier Jun 14, 2023
9403873
Re-increate Dexie version number
binwiederhier Jun 14, 2023
4dc89f6
Tiny fixes
binwiederhier Jun 14, 2023
eebe4f8
Refactor and document sw.js file
nimbleghost Jun 14, 2023
2dcad15
Add missing await
nimbleghost Jun 14, 2023
83eb4c3
Add i18n to service worker
nimbleghost Jun 14, 2023
fa418ee
Update develop.md sw docs
nimbleghost Jun 14, 2023
b197ea3
Use the same notification pipeline everywhere
nimbleghost Jun 14, 2023
6e95d62
Cosmetic changess
binwiederhier Jun 16, 2023
c43a116
Docs, mostly
binwiederhier Jun 16, 2023
341e84f
Limit number of webpush subscriptions per subscriber IP
binwiederhier Jun 17, 2023
ff7e894
Add more tests, change endpoint
binwiederhier Jun 17, 2023
2d45e39
Add disabled web push test
nimbleghost Jun 17, 2023
3cd61d8
Add web push delete test
nimbleghost Jun 17, 2023
b7bb445
Check for image mimetype first
nimbleghost Jun 17, 2023
fafe478
Sync localStorage to indexedDB on startup
nimbleghost Jun 17, 2023
9ba733d
Add a reload button to error boundary
nimbleghost Jun 17, 2023
30a8f66
Reorder start/stopWorkers
nimbleghost Jun 17, 2023
020996e
Minor changes
binwiederhier Jun 18, 2023
88c6b4a
Rename web-push-subscriptions-file to web-push-file
binwiederhier Jun 18, 2023
dc7dd83
web-push-startup-queries
binwiederhier Jun 18, 2023
89f5cc5
Doc fixes
nimbleghost Jun 18, 2023
5ce7866
Doc fixes (2)
nimbleghost Jun 18, 2023
27a4e58
Merge branch 'main' into pwa
binwiederhier Jun 19, 2023
6615aea
Fix grant button in language files
binwiederhier Jun 19, 2023
d7aacb8
Fix PWA for non-root web roots
nimbleghost Jun 19, 2023
000a3e0
Improve dynamic webmanifest setup
nimbleghost Jun 19, 2023
8211b4c
Fix: add v1 to navigation fallback denylist
nimbleghost Jun 19, 2023
5f6d753
Remove navigation fallback for all except app root
nimbleghost Jun 19, 2023
f61c67e
Translated using Weblate (Turkish)
oersen Jun 19, 2023
d266579
Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into pwa
binwiederhier Jun 21, 2023
c400c55
Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web
binwiederhier Jun 21, 2023
141565d
Merge branch 'main' into pwa
binwiederhier Jun 21, 2023
271056a
The last commit
binwiederhier Jun 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions cmd/webpush.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ func generateWebPushKeys(c *cli.Context) error {
if err != nil {
return err
}

fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
_, 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
Expand All @@ -45,6 +44,5 @@ web-push-email-address: <email address>

See https://ntfy.sh/docs/config/#web-push for details.
`, publicKey, privateKey)

return nil
return err
}
2 changes: 1 addition & 1 deletion server/server_web_push.go → server/server_webpush.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *
}
}
}
if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), req.Topics); err != nil {
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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -225,7 +226,7 @@ func payloadForTopics(t *testing.T, topics []string, endpoint string) string {
}

func addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) {
require.Nil(t, s.webPush.UpsertSubscription(endpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", "u_123", topics)) // Test auth and p256dh
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) {
Expand Down
57 changes: 47 additions & 10 deletions server/webpush_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@ 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
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionLimitPerSubscriberIP = 10
binwiederhier marked this conversation as resolved.
Show resolved Hide resolved
)

var (
errWebPushNoRows = errors.New("no rows found")
binwiederhier marked this conversation as resolved.
Show resolved Hide resolved
errWebPushTooManySubscriptions = errors.New("too many subscriptions")
)

const (
Expand All @@ -21,11 +29,13 @@ const (
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id 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,
Expand All @@ -43,19 +53,20 @@ const (
PRAGMA foreign_keys = ON;
`

selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
selectWebPushSubscriptionsForTopicQuery = `
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 = ?
`
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, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
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, updated_at = excluded.updated_at, warned_at = excluded.warned_at
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 = ?`
Expand Down Expand Up @@ -119,12 +130,28 @@ func runWebPushStartupQueries(db *sql.DB) error {

// 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, topics []string) error {
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 {
Expand All @@ -137,14 +164,17 @@ func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID
return err
}
} else {
if subscriptionCount >= subscriptionLimitPerSubscriberIP {
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, updatedAt, warnedAt); err != nil {
if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
return err
}
// Replace all subscription topics
Expand All @@ -159,6 +189,7 @@ func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID
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 {
Expand All @@ -168,6 +199,7 @@ func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscripti
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 {
Expand All @@ -177,6 +209,7 @@ func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPus
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 {
Expand Down Expand Up @@ -209,21 +242,25 @@ func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscrip
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 {
_, 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()
}
43 changes: 43 additions & 0 deletions server/webpush_store_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package server

import (
"fmt"
"github.com/stretchr/testify/require"
"net/netip"
"path/filepath"
"testing"
)

Expand All @@ -10,3 +13,43 @@ func newTestWebPushStore(t *testing.T, filename string) *webPushStore {
require.Nil(t, err)
return webPush
}

func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
webPush := newTestWebPushStore(t, filepath.Join(t.TempDir(), "webpush.db"))
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, filepath.Join(t.TempDir(), "webpush.db"))
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"}))
}