Skip to content

feat(site): add webpush notification serviceworker #17123

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

Merged
merged 8 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 5 additions & 1 deletion cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import (
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/unhanger"
"github.com/coder/coder/v2/coderd/updatecheck"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
Expand Down Expand Up @@ -779,7 +780,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// Manage push notifications.
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
if experiments.Enabled(codersdk.ExperimentWebPush) {
webpusher, err := webpush.New(ctx, &options.Logger, options.Database)
if !strings.HasPrefix(options.AccessURL.String(), "https://") {
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()))
}
webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String())
if err != nil {
options.Logger.Error(ctx, "failed to create web push dispatcher", slog.Error(err))
options.Logger.Warn(ctx, "web push notifications will not work until the VAPID keys are regenerated")
Expand Down
2 changes: 1 addition & 1 deletion coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can

if options.WebpushDispatcher == nil {
// nolint:gocritic // Gets/sets VAPID keys.
pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database)
pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database, "http://example.com")
if err != nil {
panic(xerrors.Errorf("failed to create web push notifier: %w", err))
}
Expand Down
12 changes: 11 additions & 1 deletion coderd/webpush/webpush.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ type Dispatcher interface {
// for updates inside of a workspace, which we want to be immediate.
//
// See: https://github.com/coder/internal/issues/528
func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher, error) {
func New(ctx context.Context, log *slog.Logger, db database.Store, vapidSub string) (Dispatcher, error) {
keys, err := db.GetWebpushVAPIDKeys(ctx)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, xerrors.Errorf("get notification vapid keys: %w", err)
}
}

if keys.VapidPublicKey == "" || keys.VapidPrivateKey == "" {
// Generate new VAPID keys. This also deletes all existing push
// subscriptions as part of the transaction, as they are no longer
Expand All @@ -62,6 +63,7 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher,
}

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

// public and private keys for VAPID. These are used to sign and encrypt
// the message payload.
VAPIDPublicKey string
VAPIDPrivateKey string
}
Expand Down Expand Up @@ -148,10 +156,12 @@ func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string
Endpoint: endpoint,
Keys: keys,
}, &webpush.Options{
Subscriber: n.vapidSub,
VAPIDPublicKey: n.VAPIDPublicKey,
VAPIDPrivateKey: n.VAPIDPrivateKey,
})
if err != nil {
n.log.Error(ctx, "failed to send webpush notification", slog.Error(err), slog.F("endpoint", endpoint))
return -1, nil, xerrors.Errorf("send webpush notification: %w", err)
}
defer resp.Body.Close()
Expand Down
101 changes: 52 additions & 49 deletions coderd/webpush/webpush_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package webpush_test

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -32,7 +34,9 @@ func TestPush(t *testing.T) {
t.Run("SuccessfulDelivery", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
msg := randomWebpushMessage(t)
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusOK)
})
user := dbgen.User(t, store, database.User{})
Expand All @@ -45,16 +49,7 @@ func TestPush(t *testing.T) {
})
require.NoError(t, err)

notification := codersdk.WebpushMessage{
Title: "Test Title",
Body: "Test Body",
Actions: []codersdk.WebpushMessageAction{
{Label: "View", URL: "https://coder.com/view"},
},
Icon: "workspace",
}

err = manager.Dispatch(ctx, user.ID, notification)
err = manager.Dispatch(ctx, user.ID, msg)
require.NoError(t, err)

subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
Expand All @@ -66,7 +61,8 @@ func TestPush(t *testing.T) {
t.Run("ExpiredSubscription", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusGone)
})
user := dbgen.User(t, store, database.User{})
Expand All @@ -79,12 +75,8 @@ func TestPush(t *testing.T) {
})
require.NoError(t, err)

notification := codersdk.WebpushMessage{
Title: "Test Title",
Body: "Test Body",
}

err = manager.Dispatch(ctx, user.ID, notification)
msg := randomWebpushMessage(t)
err = manager.Dispatch(ctx, user.ID, msg)
require.NoError(t, err)

subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID)
Expand All @@ -95,7 +87,8 @@ func TestPush(t *testing.T) {
t.Run("FailedDelivery", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid request"))
})
Expand All @@ -110,12 +103,8 @@ func TestPush(t *testing.T) {
})
require.NoError(t, err)

notification := codersdk.WebpushMessage{
Title: "Test Title",
Body: "Test Body",
}

err = manager.Dispatch(ctx, user.ID, notification)
msg := randomWebpushMessage(t)
err = manager.Dispatch(ctx, user.ID, msg)
require.Error(t, err)
assert.Contains(t, err.Error(), "Invalid request")

Expand All @@ -130,13 +119,15 @@ func TestPush(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
var okEndpointCalled bool
var goneEndpointCalled bool
manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
okEndpointCalled = true
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusOK)
})

serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
goneEndpointCalled = true
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusGone)
}))
defer serverGone.Close()
Expand All @@ -163,15 +154,8 @@ func TestPush(t *testing.T) {
})
require.NoError(t, err)

notification := codersdk.WebpushMessage{
Title: "Test Title",
Body: "Test Body",
Actions: []codersdk.WebpushMessageAction{
{Label: "View", URL: "https://coder.com/view"},
},
}

err = manager.Dispatch(ctx, user.ID, notification)
msg := randomWebpushMessage(t)
err = manager.Dispatch(ctx, user.ID, msg)
require.NoError(t, err)
assert.True(t, okEndpointCalled, "The valid endpoint should be called")
assert.True(t, goneEndpointCalled, "The expired endpoint should be called")
Expand All @@ -189,8 +173,9 @@ func TestPush(t *testing.T) {

ctx := testutil.Context(t, testutil.WaitShort)
var requestReceived bool
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) {
manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, r *http.Request) {
requestReceived = true
assertWebpushPayload(t, r)
w.WriteHeader(http.StatusOK)
})

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

notification := codersdk.WebpushMessage{
Title: "Test Notification",
Body: "This is a test notification body",
Actions: []codersdk.WebpushMessageAction{
{Label: "View Workspace", URL: "https://coder.com/workspace/123"},
{Label: "Cancel", URL: "https://coder.com/cancel"},
},
Icon: "workspace-icon",
}

err = manager.Dispatch(ctx, user.ID, notification)
msg := randomWebpushMessage(t)
err = manager.Dispatch(ctx, user.ID, msg)
require.NoError(t, err, "The push notification should be dispatched successfully")
require.True(t, requestReceived, "The push notification request should have been received by the server")
})
Expand All @@ -242,15 +218,42 @@ func TestPush(t *testing.T) {
})
}

func randomWebpushMessage(t testing.TB) codersdk.WebpushMessage {
t.Helper()
return codersdk.WebpushMessage{
Title: testutil.GetRandomName(t),
Body: testutil.GetRandomName(t),

Actions: []codersdk.WebpushMessageAction{
{Label: "A", URL: "https://example.com/a"},
{Label: "B", URL: "https://example.com/b"},
},
Icon: "https://example.com/icon.png",
}
}

func assertWebpushPayload(t testing.TB, r *http.Request) {
t.Helper()
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/octet-stream", r.Header.Get("Content-Type"))
assert.Equal(t, r.Header.Get("content-encoding"), "aes128gcm")
assert.Contains(t, r.Header.Get("Authorization"), "vapid")

// Attempting to decode the request body as JSON should fail as it is
// encrypted.
assert.Error(t, json.NewDecoder(r.Body).Decode(io.Discard))
}

// setupPushTest creates a common test setup for webpush notification tests
func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (webpush.Dispatcher, database.Store, string) {
t.Helper()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
db, _ := dbtestutil.NewDB(t)

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

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

return manager, db, server.URL
Expand Down
22 changes: 22 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2371,6 +2371,28 @@ class ApiMethods {
await this.axios.post<void>("/api/v2/notifications/test");
};

createWebPushSubscription = async (
userId: string,
req: TypesGen.WebpushSubscription,
) => {
await this.axios.post<void>(
`/api/v2/users/${userId}/webpush/subscription`,
req,
);
};

deleteWebPushSubscription = async (
userId: string,
req: TypesGen.DeleteWebpushSubscription,
) => {
await this.axios.delete<void>(
`/api/v2/users/${userId}/webpush/subscription`,
{
data: req,
},
);
};

requestOneTimePassword = async (
req: TypesGen.RequestOneTimePasscodeRequest,
) => {
Expand Down
Loading
Loading