Skip to content

Commit e1f27a7

Browse files
authored
feat(site): add webpush notification serviceworker (#17123)
* Improves tests for webpush notifications * Sets subscriber correctly in web push payload (without this, notifications do not work in Safari) * NOTE: for now, I'm using the Coder Access URL. Some push messaging service don't like it when you use a non-HTTPS URL, so dropping a warn log about this. * Adds a service worker and context for push notifications * Adds a button beside "Inbox" to enable / disable push notifications Notes: * ✅ Tested in in Firefox and Safari, and Chrome.
1 parent 661ed23 commit e1f27a7

File tree

11 files changed

+285
-52
lines changed

11 files changed

+285
-52
lines changed

cli/server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import (
9595
"github.com/coder/coder/v2/coderd/tracing"
9696
"github.com/coder/coder/v2/coderd/unhanger"
9797
"github.com/coder/coder/v2/coderd/updatecheck"
98+
"github.com/coder/coder/v2/coderd/util/ptr"
9899
"github.com/coder/coder/v2/coderd/util/slice"
99100
stringutil "github.com/coder/coder/v2/coderd/util/strings"
100101
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
@@ -779,7 +780,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
779780
// Manage push notifications.
780781
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
781782
if experiments.Enabled(codersdk.ExperimentWebPush) {
782-
webpusher, err := webpush.New(ctx, &options.Logger, options.Database)
783+
if !strings.HasPrefix(options.AccessURL.String(), "https://") {
784+
options.Logger.Warn(ctx, "access URL is not HTTPS, so web push notifications may not work on some browsers", slog.F("access_url", options.AccessURL.String()))
785+
}
786+
webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String())
783787
if err != nil {
784788
options.Logger.Error(ctx, "failed to create web push dispatcher", slog.Error(err))
785789
options.Logger.Warn(ctx, "web push notifications will not work until the VAPID keys are regenerated")

coderd/coderdtest/coderdtest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
284284

285285
if options.WebpushDispatcher == nil {
286286
// nolint:gocritic // Gets/sets VAPID keys.
287-
pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database)
287+
pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database, "http://example.com")
288288
if err != nil {
289289
panic(xerrors.Errorf("failed to create web push notifier: %w", err))
290290
}

coderd/webpush/webpush.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ type Dispatcher interface {
4141
// for updates inside of a workspace, which we want to be immediate.
4242
//
4343
// See: https://github.com/coder/internal/issues/528
44-
func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher, error) {
44+
func New(ctx context.Context, log *slog.Logger, db database.Store, vapidSub string) (Dispatcher, error) {
4545
keys, err := db.GetWebpushVAPIDKeys(ctx)
4646
if err != nil {
4747
if !errors.Is(err, sql.ErrNoRows) {
4848
return nil, xerrors.Errorf("get notification vapid keys: %w", err)
4949
}
5050
}
51+
5152
if keys.VapidPublicKey == "" || keys.VapidPrivateKey == "" {
5253
// Generate new VAPID keys. This also deletes all existing push
5354
// subscriptions as part of the transaction, as they are no longer
@@ -62,6 +63,7 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher,
6263
}
6364

6465
return &Webpusher{
66+
vapidSub: vapidSub,
6567
store: db,
6668
log: log,
6769
VAPIDPublicKey: keys.VapidPublicKey,
@@ -72,7 +74,13 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher,
7274
type Webpusher struct {
7375
store database.Store
7476
log *slog.Logger
77+
// VAPID allows us to identify the sender of the message.
78+
// This must be a https:// URL or an email address.
79+
// Some push services (such as Apple's) require this to be set.
80+
vapidSub string
7581

82+
// public and private keys for VAPID. These are used to sign and encrypt
83+
// the message payload.
7684
VAPIDPublicKey string
7785
VAPIDPrivateKey string
7886
}
@@ -148,10 +156,12 @@ func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string
148156
Endpoint: endpoint,
149157
Keys: keys,
150158
}, &webpush.Options{
159+
Subscriber: n.vapidSub,
151160
VAPIDPublicKey: n.VAPIDPublicKey,
152161
VAPIDPrivateKey: n.VAPIDPrivateKey,
153162
})
154163
if err != nil {
164+
n.log.Error(ctx, "failed to send webpush notification", slog.Error(err), slog.F("endpoint", endpoint))
155165
return -1, nil, xerrors.Errorf("send webpush notification: %w", err)
156166
}
157167
defer resp.Body.Close()

coderd/webpush/webpush_test.go

Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package webpush_test
22

33
import (
44
"context"
5+
"encoding/json"
6+
"io"
57
"net/http"
68
"net/http/httptest"
79
"testing"
@@ -32,7 +34,9 @@ func TestPush(t *testing.T) {
3234
t.Run("SuccessfulDelivery", func(t *testing.T) {
3335
t.Parallel()
3436
ctx := testutil.Context(t, testutil.WaitShort)
35-
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
37+
msg := randomWebpushMessage(t)
38+
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
39+
assertWebpushPayload(t, r)
3640
w.WriteHeader(http.StatusOK)
3741
})
3842
user := dbgen.User(t, store, database.User{})
@@ -45,16 +49,7 @@ func TestPush(t *testing.T) {
4549
})
4650
require.NoError(t, err)
4751

48-
notification := codersdk.WebpushMessage{
49-
Title: "Test Title",
50-
Body: "Test Body",
51-
Actions: []codersdk.WebpushMessageAction{
52-
{Label: "View", URL: "https://coder.com/view"},
53-
},
54-
Icon: "workspace",
55-
}
56-
57-
err = manager.Dispatch(ctx, user.ID, notification)
52+
err = manager.Dispatch(ctx, user.ID, msg)
5853
require.NoError(t, err)
5954

6055
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
@@ -66,7 +61,8 @@ func TestPush(t *testing.T) {
6661
t.Run("ExpiredSubscription", func(t *testing.T) {
6762
t.Parallel()
6863
ctx := testutil.Context(t, testutil.WaitShort)
69-
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
64+
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
65+
assertWebpushPayload(t, r)
7066
w.WriteHeader(http.StatusGone)
7167
})
7268
user := dbgen.User(t, store, database.User{})
@@ -79,12 +75,8 @@ func TestPush(t *testing.T) {
7975
})
8076
require.NoError(t, err)
8177

82-
notification := codersdk.WebpushMessage{
83-
Title: "Test Title",
84-
Body: "Test Body",
85-
}
86-
87-
err = manager.Dispatch(ctx, user.ID, notification)
78+
msg := randomWebpushMessage(t)
79+
err = manager.Dispatch(ctx, user.ID, msg)
8880
require.NoError(t, err)
8981

9082
subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
@@ -95,7 +87,8 @@ func TestPush(t *testing.T) {
9587
t.Run("FailedDelivery", func(t *testing.T) {
9688
t.Parallel()
9789
ctx := testutil.Context(t, testutil.WaitShort)
98-
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
90+
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
91+
assertWebpushPayload(t, r)
9992
w.WriteHeader(http.StatusBadRequest)
10093
w.Write([]byte("Invalid request"))
10194
})
@@ -110,12 +103,8 @@ func TestPush(t *testing.T) {
110103
})
111104
require.NoError(t, err)
112105

113-
notification := codersdk.WebpushMessage{
114-
Title: "Test Title",
115-
Body: "Test Body",
116-
}
117-
118-
err = manager.Dispatch(ctx, user.ID, notification)
106+
msg := randomWebpushMessage(t)
107+
err = manager.Dispatch(ctx, user.ID, msg)
119108
require.Error(t, err)
120109
assert.Contains(t, err.Error(), "Invalid request")
121110

@@ -130,13 +119,15 @@ func TestPush(t *testing.T) {
130119
ctx := testutil.Context(t, testutil.WaitShort)
131120
var okEndpointCalled bool
132121
var goneEndpointCalled bool
133-
manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
122+
manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
134123
okEndpointCalled = true
124+
assertWebpushPayload(t, r)
135125
w.WriteHeader(http.StatusOK)
136126
})
137127

138-
serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
128+
serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139129
goneEndpointCalled = true
130+
assertWebpushPayload(t, r)
140131
w.WriteHeader(http.StatusGone)
141132
}))
142133
defer serverGone.Close()
@@ -163,15 +154,8 @@ func TestPush(t *testing.T) {
163154
})
164155
require.NoError(t, err)
165156

166-
notification := codersdk.WebpushMessage{
167-
Title: "Test Title",
168-
Body: "Test Body",
169-
Actions: []codersdk.WebpushMessageAction{
170-
{Label: "View", URL: "https://coder.com/view"},
171-
},
172-
}
173-
174-
err = manager.Dispatch(ctx, user.ID, notification)
157+
msg := randomWebpushMessage(t)
158+
err = manager.Dispatch(ctx, user.ID, msg)
175159
require.NoError(t, err)
176160
assert.True(t, okEndpointCalled, "The valid endpoint should be called")
177161
assert.True(t, goneEndpointCalled, "The expired endpoint should be called")
@@ -189,8 +173,9 @@ func TestPush(t *testing.T) {
189173

190174
ctx := testutil.Context(t, testutil.WaitShort)
191175
var requestReceived bool
192-
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
176+
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
193177
requestReceived = true
178+
assertWebpushPayload(t, r)
194179
w.WriteHeader(http.StatusOK)
195180
})
196181

@@ -205,17 +190,8 @@ func TestPush(t *testing.T) {
205190
})
206191
require.NoError(t, err, "Failed to insert push subscription")
207192

208-
notification := codersdk.WebpushMessage{
209-
Title: "Test Notification",
210-
Body: "This is a test notification body",
211-
Actions: []codersdk.WebpushMessageAction{
212-
{Label: "View Workspace", URL: "https://coder.com/workspace/123"},
213-
{Label: "Cancel", URL: "https://coder.com/cancel"},
214-
},
215-
Icon: "workspace-icon",
216-
}
217-
218-
err = manager.Dispatch(ctx, user.ID, notification)
193+
msg := randomWebpushMessage(t)
194+
err = manager.Dispatch(ctx, user.ID, msg)
219195
require.NoError(t, err, "The push notification should be dispatched successfully")
220196
require.True(t, requestReceived, "The push notification request should have been received by the server")
221197
})
@@ -242,15 +218,42 @@ func TestPush(t *testing.T) {
242218
})
243219
}
244220

221+
func randomWebpushMessage(t testing.TB) codersdk.WebpushMessage {
222+
t.Helper()
223+
return codersdk.WebpushMessage{
224+
Title: testutil.GetRandomName(t),
225+
Body: testutil.GetRandomName(t),
226+
227+
Actions: []codersdk.WebpushMessageAction{
228+
{Label: "A", URL: "https://example.com/a"},
229+
{Label: "B", URL: "https://example.com/b"},
230+
},
231+
Icon: "https://example.com/icon.png",
232+
}
233+
}
234+
235+
func assertWebpushPayload(t testing.TB, r *http.Request) {
236+
t.Helper()
237+
assert.Equal(t, http.MethodPost, r.Method)
238+
assert.Equal(t, "application/octet-stream", r.Header.Get("Content-Type"))
239+
assert.Equal(t, r.Header.Get("content-encoding"), "aes128gcm")
240+
assert.Contains(t, r.Header.Get("Authorization"), "vapid")
241+
242+
// Attempting to decode the request body as JSON should fail as it is
243+
// encrypted.
244+
assert.Error(t, json.NewDecoder(r.Body).Decode(io.Discard))
245+
}
246+
245247
// setupPushTest creates a common test setup for webpush notification tests
246248
func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (webpush.Dispatcher, database.Store, string) {
249+
t.Helper()
247250
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
248251
db, _ := dbtestutil.NewDB(t)
249252

250253
server := httptest.NewServer(http.HandlerFunc(handlerFunc))
251254
t.Cleanup(server.Close)
252255

253-
manager, err := webpush.New(ctx, &logger, db)
256+
manager, err := webpush.New(ctx, &logger, db, "http://example.com")
254257
require.NoError(t, err, "Failed to create webpush manager")
255258

256259
return manager, db, server.URL

site/src/api/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2371,6 +2371,28 @@ class ApiMethods {
23712371
await this.axios.post<void>("/api/v2/notifications/test");
23722372
};
23732373

2374+
createWebPushSubscription = async (
2375+
userId: string,
2376+
req: TypesGen.WebpushSubscription,
2377+
) => {
2378+
await this.axios.post<void>(
2379+
`/api/v2/users/${userId}/webpush/subscription`,
2380+
req,
2381+
);
2382+
};
2383+
2384+
deleteWebPushSubscription = async (
2385+
userId: string,
2386+
req: TypesGen.DeleteWebpushSubscription,
2387+
) => {
2388+
await this.axios.delete<void>(
2389+
`/api/v2/users/${userId}/webpush/subscription`,
2390+
{
2391+
data: req,
2392+
},
2393+
);
2394+
};
2395+
23742396
requestOneTimePassword = async (
23752397
req: TypesGen.RequestOneTimePasscodeRequest,
23762398
) => {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy