1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2025-09-02 01:55:14 +02:00

Simplify web push UX and updates

- Use a single endpoint
- Use a declarative web push sync hook. This thus handles all edge cases
  that had to be manually handled before: logout, login, account sync,
  etc.
- Simplify UX: browser notifications are always enabled (unless denied),
  web push toggle only shows up if permissions are already granted.
This commit is contained in:
nimbleghost 2023-06-02 13:22:54 +02:00
parent 4944e3ae4b
commit 47ad024ec7
20 changed files with 294 additions and 427 deletions

View file

@ -67,17 +67,15 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
var (
// If changed, don't forget to update Android App and auth_sqlite.go
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
webPushSubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/subscribe$`)
webPushUnsubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/unsubscribe$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
@ -96,6 +94,7 @@ var (
apiAccountSettingsPath = "/v1/account/settings"
apiAccountSubscriptionPath = "/v1/account/subscription"
apiAccountReservationPath = "/v1/account/reservation"
apiAccountWebPushPath = "/v1/account/web-push"
apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal"
@ -525,10 +524,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodPost && webPushSubscribePathRegex.MatchString(r.URL.Path) {
return s.ensureWebPushEnabled(s.limitRequestsWithTopic(s.authorizeTopicRead(s.handleTopicWebPushSubscribe)))(w, r, v)
} else if r.Method == http.MethodPost && webPushUnsubscribePathRegex.MatchString(r.URL.Path) {
return s.ensureWebPushEnabled(s.limitRequestsWithTopic(s.authorizeTopicRead(s.handleTopicWebPushUnsubscribe)))(w, r, v)
} else if r.Method == http.MethodPut && apiAccountWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
}

View file

@ -3,40 +3,36 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/log"
"net/http"
"heckel.io/ntfy/user"
)
func (s *Server) handleTopicWebPushSubscribe(w http.ResponseWriter, r *http.Request, v *visitor) error {
sub, err := readJSONWithLimit[webPushSubscribePayload](r.Body, jsonBodyBytesLimit, false)
if err != nil || sub.BrowserSubscription.Endpoint == "" || sub.BrowserSubscription.Keys.P256dh == "" || sub.BrowserSubscription.Keys.Auth == "" {
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
payload, err := readJSONWithLimit[webPushSubscriptionPayload](r.Body, jsonBodyBytesLimit, false)
if err != nil || payload.BrowserSubscription.Endpoint == "" || payload.BrowserSubscription.Keys.P256dh == "" || payload.BrowserSubscription.Keys.Auth == "" {
return errHTTPBadRequestWebPushSubscriptionInvalid
}
topic, err := fromContext[*topic](r, contextTopic)
if err != nil {
return err
}
if err = s.webPush.AddSubscription(topic.ID, v.MaybeUserID(), *sub); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
u := v.User()
func (s *Server) handleTopicWebPushUnsubscribe(w http.ResponseWriter, r *http.Request, _ *visitor) error {
payload, err := readJSONWithLimit[webPushUnsubscribePayload](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return errHTTPBadRequestWebPushSubscriptionInvalid
}
topic, err := fromContext[*topic](r, contextTopic)
topics, err := s.topicsFromIDs(payload.Topics...)
if err != nil {
return err
}
err = s.webPush.RemoveSubscription(topic.ID, payload.Endpoint)
if err != nil {
if s.userManager != nil {
for _, t := range topics {
if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
return errHTTPForbidden.With(t)
}
}
}
if err := s.webPush.UpdateSubscriptions(payload.Topics, v.MaybeUserID(), payload.BrowserSubscription); err != nil {
return err
}

View file

@ -1,6 +1,8 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@ -14,22 +16,10 @@ import (
"heckel.io/ntfy/util"
)
var (
webPushSubscribePayloadExample = `{
"browser_subscription":{
"endpoint": "https://example.com/webpush",
"keys": {
"p256dh": "p256dh-key",
"auth": "auth-key"
}
}
}`
)
func TestServer_WebPush_TopicSubscribe(t *testing.T) {
func TestServer_WebPush_TopicAdd(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
@ -43,6 +33,19 @@ func TestServer_WebPush_TopicSubscribe(t *testing.T) {
require.Equal(t, subs[0].UserID, "")
}
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
addSubscription(t, s, "test-topic", "https://example.com/webpush")
requireSubscriptionCount(t, s, "test-topic", 1)
response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{}), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
@ -51,7 +54,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{
response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, response.Code)
@ -68,38 +71,20 @@ func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil)
require.Equal(t, 403, response.Code)
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 1)
unsubscribe := `{"endpoint":"https://example.com/webpush"}`
response = request(t, s, "POST", "/test-topic/web-push/unsubscribe", unsubscribe, nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{
response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
@ -172,15 +157,29 @@ func TestServer_WebPush_PublishExpire(t *testing.T) {
requireSubscriptionCount(t, s, "test-topic-abc", 0)
}
func payloadForTopics(t *testing.T, topics []string) string {
topicsJson, err := json.Marshal(topics)
require.Nil(t, err)
return fmt.Sprintf(`{
"topics": %s,
"browser_subscription":{
"endpoint": "https://example.com/webpush",
"keys": {
"p256dh": "p256dh-key",
"auth": "auth-key"
}
}
}`, topicsJson)
}
func addSubscription(t *testing.T, s *Server, topic string, url string) {
err := s.webPush.AddSubscription("test-topic", "", webPushSubscribePayload{
BrowserSubscription: webpush.Subscription{
Endpoint: url,
Keys: webpush.Keys{
// connected to a local test VAPID key, not a leak!
Auth: "kSC3T8aN1JCQxxPdrFLrZg",
P256dh: "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE",
},
err := s.webPush.AddSubscription(topic, "", webpush.Subscription{
Endpoint: url,
Keys: webpush.Keys{
// connected to a local test VAPID key, not a leak!
Auth: "kSC3T8aN1JCQxxPdrFLrZg",
P256dh: "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE",
},
})
require.Nil(t, err)

View file

@ -1,12 +1,13 @@
package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"net/http"
"net/netip"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/util"
)
@ -476,10 +477,7 @@ type webPushSubscription struct {
UserID string
}
type webPushSubscribePayload struct {
type webPushSubscriptionPayload struct {
BrowserSubscription webpush.Subscription `json:"browser_subscription"`
}
type webPushUnsubscribePayload struct {
Endpoint string `json:"endpoint"`
Topics []string `json:"topics"`
}

View file

@ -2,7 +2,9 @@ package server
import (
"database/sql"
"fmt"
"github.com/SherClockHolmes/webpush-go"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
@ -69,23 +71,33 @@ func setupNewSubscriptionsDB(db *sql.DB) error {
return nil
}
func (c *webPushStore) AddSubscription(topic string, userID string, subscription webPushSubscribePayload) error {
func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error {
fmt.Printf("AAA")
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err = c.RemoveByEndpoint(subscription.Endpoint); err != nil {
return err
}
for _, topic := range topics {
if err := c.AddSubscription(topic, userID, subscription); err != nil {
return err
}
}
return tx.Commit()
}
func (c *webPushStore) AddSubscription(topic string, userID string, subscription webpush.Subscription) error {
_, err := c.db.Exec(
insertWebPushSubscriptionQuery,
topic,
userID,
subscription.BrowserSubscription.Endpoint,
subscription.BrowserSubscription.Keys.Auth,
subscription.BrowserSubscription.Keys.P256dh,
)
return err
}
func (c *webPushStore) RemoveSubscription(topic string, endpoint string) error {
_, err := c.db.Exec(
deleteWebPushSubscriptionByTopicAndEndpointQuery,
topic,
endpoint,
subscription.Endpoint,
subscription.Keys.Auth,
subscription.Keys.P256dh,
)
return err
}