Skip to content

Commit e1f27a7

Browse files
authored
feat(site): add webpush notification serviceworker (coder#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

+5-1
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

+1-1
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

+11-1
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

+52-49
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

+22
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)