From a980379e3d1792eef7c630043fbf7dd497365272 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Mar 2025 13:41:50 +0000 Subject: [PATCH 01/40] wip --- cli/server.go | 7 + coderd/apidoc/docs.go | 31 +++ coderd/apidoc/swagger.json | 31 +++ coderd/coderd.go | 34 ++- coderd/coderdtest/coderdtest.go | 12 + coderd/database/dbauthz/dbauthz.go | 42 +++ coderd/database/dbauthz/dbauthz_test.go | 38 +++ coderd/database/dbgen/dbgen.go | 13 + coderd/database/dbmem/dbmem.go | 89 +++++++ coderd/database/dbmetrics/querymetrics.go | 42 +++ coderd/database/dbmock/dbmock.go | 87 ++++++ coderd/database/dump.sql | 15 ++ coderd/database/foreign_key_constraint.go | 1 + .../000301_push_notifications.down.sql | 2 + .../000301_push_notifications.up.sql | 13 + coderd/database/models.go | 9 + coderd/database/querier.go | 6 + coderd/database/queries.sql.go | 134 ++++++++++ coderd/database/queries/notifications.sql | 18 ++ coderd/database/queries/siteconfig.sql | 13 + coderd/database/unique_constraint.go | 1 + coderd/notifications.go | 140 ++++++++++ coderd/notifications/push/push.go | 141 ++++++++++ coderd/notifications/push/push_test.go | 252 ++++++++++++++++++ coderd/notifications_test.go | 57 ++++ coderd/rbac/object_gen.go | 10 + coderd/rbac/policy/policy.go | 7 + codersdk/deployment.go | 3 + codersdk/notifications.go | 52 ++++ codersdk/rbacresources_gen.go | 2 + docs/reference/api/general.md | 1 + docs/reference/api/members.md | 5 + docs/reference/api/notifications.md | 1 + docs/reference/api/schemas.md | 59 +++- go.mod | 3 + go.sum | 32 +++ site/src/api/api.ts | 22 ++ site/src/api/rbacresourcesGenerated.ts | 5 + site/src/api/typesGenerated.ts | 30 +++ site/src/contexts/usePushNotifications.ts | 108 ++++++++ site/src/index.tsx | 5 + .../modules/dashboard/Navbar/NavbarView.tsx | 14 +- site/src/serviceWorker.ts | 56 ++++ site/src/testHelpers/entities.ts | 1 + site/vite.config.mts | 18 ++ 45 files changed, 1639 insertions(+), 23 deletions(-) create mode 100644 coderd/database/migrations/000301_push_notifications.down.sql create mode 100644 coderd/database/migrations/000301_push_notifications.up.sql create mode 100644 coderd/notifications/push/push.go create mode 100644 coderd/notifications/push/push_test.go create mode 100644 site/src/contexts/usePushNotifications.ts create mode 100644 site/src/serviceWorker.ts diff --git a/cli/server.go b/cli/server.go index 816fdb6af173c..75c9708935d41 100644 --- a/cli/server.go +++ b/cli/server.go @@ -62,6 +62,7 @@ import ( "github.com/coder/wgtunnel/tunnelsdk" "github.com/coder/coder/v2/coderd/entitlements" + "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -775,6 +776,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } + pushNotifier, err := push.New(ctx, &options.Logger, options.Database) + if err != nil { + return xerrors.Errorf("failed to create push notifier: %w", err) + } + options.PushNotifier = pushNotifier + githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) if err != nil { return xerrors.Errorf("get github oauth2 config params: %w", err) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e570e95a8d9bc..f4a51e42bd959 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10709,6 +10709,10 @@ const docTemplate = `{ "description": "ProvisionerAPIVersion is the current version of the Provisioner API", "type": "string" }, + "push_notifications_public_key": { + "description": "PushNotificationsPublicKey is the public key for push notifications.", + "type": "string" + }, "telemetry": { "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", "type": "boolean" @@ -11497,6 +11501,14 @@ const docTemplate = `{ } } }, + "codersdk.DeletePushNotificationSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + } + }, "codersdk.DeleteWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -14013,6 +14025,20 @@ const docTemplate = `{ "ProxyUnregistered" ] }, + "codersdk.PushNotificationSubscription": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "p256dh_key": { + "type": "string" + } + } + }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": [ @@ -14098,6 +14124,7 @@ const docTemplate = `{ "license", "notification_message", "notification_preference", + "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -14135,6 +14162,7 @@ const docTemplate = `{ "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", + "ResourceNotificationPushSubscription", "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", @@ -15485,6 +15513,9 @@ const docTemplate = `{ "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { + "push_subscription": { + "type": "string" + }, "template_disabled_map": { "type": "object", "additionalProperties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 606cb76ade16c..80313c4dc7d6a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9531,6 +9531,10 @@ "description": "ProvisionerAPIVersion is the current version of the Provisioner API", "type": "string" }, + "push_notifications_public_key": { + "description": "PushNotificationsPublicKey is the public key for push notifications.", + "type": "string" + }, "telemetry": { "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", "type": "boolean" @@ -10261,6 +10265,14 @@ } } }, + "codersdk.DeletePushNotificationSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + } + }, "codersdk.DeleteWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -12682,6 +12694,20 @@ "ProxyUnregistered" ] }, + "codersdk.PushNotificationSubscription": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "p256dh_key": { + "type": "string" + } + } + }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": ["deadline"], @@ -12762,6 +12788,7 @@ "license", "notification_message", "notification_preference", + "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -12799,6 +12826,7 @@ "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", + "ResourceNotificationPushSubscription", "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", @@ -14094,6 +14122,9 @@ "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { + "push_subscription": { + "type": "string" + }, "template_disabled_map": { "type": "object", "additionalProperties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 3fbbd756eae72..5f8de6def2379 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -44,6 +44,7 @@ import ( "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" + "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/runtimeconfig" agentproto "github.com/coder/coder/v2/agent/proto" @@ -260,6 +261,9 @@ type Options struct { AppEncryptionKeyCache cryptokeys.EncryptionKeycache OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock + + // PushNotifier is a way to send push notifications to users. + PushNotifier *push.Notifier } // @title Coder API @@ -546,6 +550,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, Experiments: experiments, + PushNotifier: options.PushNotifier, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( ctx, @@ -572,15 +577,16 @@ func New(options *Options) *API { api.AppearanceFetcher.Store(&f) api.PortSharer.Store(&portsharing.DefaultPortSharer) buildInfo := codersdk.BuildInfoResponse{ - ExternalURL: buildinfo.ExternalURL(), - Version: buildinfo.Version(), - AgentAPIVersion: AgentAPIVersionREST, - ProvisionerAPIVersion: proto.CurrentVersion.String(), - DashboardURL: api.AccessURL.String(), - WorkspaceProxy: false, - UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), - DeploymentID: api.DeploymentID, - Telemetry: api.Telemetry.Enabled(), + ExternalURL: buildinfo.ExternalURL(), + Version: buildinfo.Version(), + AgentAPIVersion: AgentAPIVersionREST, + ProvisionerAPIVersion: proto.CurrentVersion.String(), + DashboardURL: api.AccessURL.String(), + WorkspaceProxy: false, + UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), + DeploymentID: api.DeploymentID, + PushNotificationsPublicKey: api.PushNotifier.VAPIDPublicKey, + Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ BinFS: binFS, @@ -1194,6 +1200,10 @@ func New(options *Options) *API { r.Get("/", api.userNotificationPreferences) r.Put("/", api.putUserNotificationPreferences) }) + r.Route("/push", func(r chi.Router) { + r.Post("/subscription", api.postUserPushNotificationSubscription) + r.Delete("/subscription", api.deleteUserPushNotificationSubscription) + }) }) }) }) @@ -1494,8 +1504,10 @@ type API struct { TailnetCoordinator atomic.Pointer[tailnet.Coordinator] NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher TailnetClientService *tailnet.ClientService - QuotaCommitter atomic.Pointer[proto.QuotaCommitter] - AppearanceFetcher atomic.Pointer[appearance.Fetcher] + // PushNotifier is a way to send push notifications to users. + PushNotifier *push.Notifier + QuotaCommitter atomic.Pointer[proto.QuotaCommitter] + AppearanceFetcher atomic.Pointer[appearance.Fetcher] // WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies // for header reasons. WorkspaceProxyHostsFn atomic.Pointer[func() []string] diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6b435157a2e95..24a9b6c257e96 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -70,6 +70,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" + "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -161,6 +162,7 @@ type Options struct { Logger *slog.Logger StatsBatcher workspacestats.Batcher + PushNotifier *push.Notifier WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions AllowWorkspaceRenames bool NewTicker func(duration time.Duration) (<-chan time.Time, func()) @@ -280,6 +282,15 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can require.NoError(t, err, "insert a deployment id") } + if options.PushNotifier == nil { + // nolint:gocritic // Gets/sets VAPID keys. + pushNotifier, err := push.New(dbauthz.AsSystemRestricted(context.Background()), options.Logger, options.Database) + if err != nil { + panic(xerrors.Errorf("failed to create push notifier: %w", err)) + } + options.PushNotifier = pushNotifier + } + if options.DeploymentValues == nil { options.DeploymentValues = DeploymentValues(t) } @@ -530,6 +541,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can TrialGenerator: options.TrialGenerator, RefreshEntitlements: options.RefreshEntitlements, TailnetCoordinator: options.Coordinator, + PushNotifier: options.PushNotifier, BaseDERPMap: derpMap, DERPMapUpdateFrequency: 150 * time.Millisecond, CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c568948aee3f9..1511a45270a56 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1245,6 +1245,20 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { return id, nil } +func (q *querier) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceNotificationPushSubscription.WithOwner(arg.UserID.String())); err != nil { + return err + } + return q.db.DeleteNotificationPushSubscriptionByEndpoint(ctx, arg) +} + +func (q *querier) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return err + } + return q.db.DeleteNotificationPushSubscriptions(ctx, ids) +} + func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2App); err != nil { return err @@ -1867,6 +1881,13 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } +func (q *querier) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationPushSubscription.WithOwner(userID.String())); err != nil { + return nil, err + } + return q.db.GetNotificationPushSubscriptionsByUserID(ctx, userID) +} + func (q *querier) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return database.NotificationReportGeneratorLog{}, err @@ -1891,6 +1912,13 @@ func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind datab return nil, sql.ErrNoRows } +func (q *querier) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.GetNotificationVAPIDKeysRow{}, err + } + return q.db.GetNotificationVAPIDKeys(ctx) +} + func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) { // No authz checks return q.db.GetNotificationsSettings(ctx) @@ -3201,6 +3229,13 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi return q.db.InsertMissingGroups(ctx, arg) } +func (q *querier) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceNotificationPushSubscription.WithOwner(arg.UserID.String())); err != nil { + return database.NotificationPushSubscription{}, err + } + return q.db.InsertNotificationPushSubscription(ctx, arg) +} + func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -4575,6 +4610,13 @@ func (q *querier) UpsertNotificationReportGeneratorLog(ctx context.Context, arg return q.db.UpsertNotificationReportGeneratorLog(ctx, arg) } +func (q *querier) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertNotificationVAPIDKeys(ctx, arg) +} + func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 16414b249ae05..021fba56799cb 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4531,6 +4531,15 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("UpsertOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Errors(sql.ErrNoRows) + })) + s.Run("UpsertNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertNotificationVAPIDKeysParams{ + VapidPublicKey: "test", + VapidPrivateKey: "test", + }).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestNotifications() { @@ -4568,6 +4577,35 @@ func (s *MethodTestSuite) TestNotifications() { }).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) })) + // Notification push subscriptions + s.Run("GetNotificationPushSubscriptionsByUserID", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionRead) + })) + s.Run("InsertNotificationPushSubscription", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(database.InsertNotificationPushSubscriptionParams{ + UserID: user.ID, + }).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) + })) + s.Run("DeleteNotificationPushSubscriptions", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + push := dbgen.NotificationPushSubscription(s.T(), db, database.InsertNotificationPushSubscriptionParams{ + UserID: user.ID, + }) + check.Args([]uuid.UUID{push.ID}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) + s.Run("DeleteNotificationPushSubscriptionByEndpoint", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + push := dbgen.NotificationPushSubscription(s.T(), db, database.InsertNotificationPushSubscriptionParams{ + UserID: user.ID, + }) + check.Args(database.DeleteNotificationPushSubscriptionByEndpointParams{ + UserID: user.ID, + Endpoint: push.Endpoint, + }).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) + })) + // Notification templates s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 3ee6a03b3d4d7..1a38c4ffff179 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -479,6 +479,19 @@ func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInbo return notification } +func NotificationPushSubscription(t testing.TB, db database.Store, orig database.InsertNotificationPushSubscriptionParams) database.NotificationPushSubscription { + subscription, err := db.InsertNotificationPushSubscription(genCtx, database.InsertNotificationPushSubscriptionParams{ + ID: takeFirst(orig.ID, uuid.New()), + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + UserID: takeFirst(orig.UserID, uuid.New()), + Endpoint: takeFirst(orig.Endpoint, testutil.GetRandomName(t)), + EndpointP256dhKey: takeFirst(orig.EndpointP256dhKey, testutil.GetRandomName(t)), + EndpointAuthKey: takeFirst(orig.EndpointAuthKey, testutil.GetRandomName(t)), + }) + require.NoError(t, err, "insert notification push subscription") + return subscription +} + func Group(t testing.TB, db database.Store, orig database.Group) database.Group { t.Helper() diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 34d900afbabfd..74576bcb7188f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -227,6 +227,7 @@ type data struct { notificationMessages []database.NotificationMessage notificationPreferences []database.NotificationPreference notificationReportGeneratorLogs []database.NotificationReportGeneratorLog + notificationPushSubscriptions []database.NotificationPushSubscription inboxNotifications []database.InboxNotification oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret @@ -289,6 +290,8 @@ type data struct { lastLicenseID int32 defaultProxyDisplayName string defaultProxyIconURL string + notificationsPushVAPIDPublicKey string + notificationsPushVAPIDPrivateKey string userStatusChanges []database.UserStatusChange telemetryItems []database.TelemetryItem presets []database.TemplateVersionPreset @@ -1998,6 +2001,36 @@ func (q *FakeQuerier) DeleteLicense(_ context.Context, id int32) (int32, error) return 0, sql.ErrNoRows } +func (q *FakeQuerier) DeleteNotificationPushSubscriptionByEndpoint(_ context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, subscription := range q.notificationPushSubscriptions { + if subscription.UserID == arg.UserID && subscription.Endpoint == arg.Endpoint { + q.notificationPushSubscriptions[i] = q.notificationPushSubscriptions[len(q.notificationPushSubscriptions)-1] + q.notificationPushSubscriptions = q.notificationPushSubscriptions[:len(q.notificationPushSubscriptions)-1] + return nil + } + } + return sql.ErrNoRows +} + +func (q *FakeQuerier) DeleteNotificationPushSubscriptions(_ context.Context, ids []uuid.UUID) error { + for i, subscription := range q.notificationPushSubscriptions { + if slices.Contains(ids, subscription.ID) { + q.notificationPushSubscriptions[i] = q.notificationPushSubscriptions[len(q.notificationPushSubscriptions)-1] + q.notificationPushSubscriptions = q.notificationPushSubscriptions[:len(q.notificationPushSubscriptions)-1] + return nil + } + } + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -3766,6 +3799,20 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } +func (q *FakeQuerier) GetNotificationPushSubscriptionsByUserID(_ context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.NotificationPushSubscription, 0) + for _, subscription := range q.notificationPushSubscriptions { + if subscription.UserID == userID { + out = append(out, subscription) + } + } + + return out, nil +} + func (q *FakeQuerier) GetNotificationReportGeneratorLogByTemplate(_ context.Context, templateID uuid.UUID) (database.NotificationReportGeneratorLog, error) { err := validateDatabaseType(templateID) if err != nil { @@ -3795,6 +3842,20 @@ func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database return nil, ErrUnimplemented } +func (q *FakeQuerier) GetNotificationVAPIDKeys(_ context.Context) (database.GetNotificationVAPIDKeysRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.notificationsPushVAPIDPublicKey == "" && q.notificationsPushVAPIDPrivateKey == "" { + return database.GetNotificationVAPIDKeysRow{}, sql.ErrNoRows + } + + return database.GetNotificationVAPIDKeysRow{ + VapidPublicKey: q.notificationsPushVAPIDPublicKey, + VapidPrivateKey: q.notificationsPushVAPIDPrivateKey, + }, nil +} + func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8456,6 +8517,20 @@ func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.Insert return newGroups, nil } +func (q *FakeQuerier) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.NotificationPushSubscription{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + subscription := database.NotificationPushSubscription(arg) + q.notificationPushSubscriptions = append(q.notificationPushSubscriptions, subscription) + return subscription, nil +} + func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { err := validateDatabaseType(arg) if err != nil { @@ -11722,6 +11797,20 @@ func (q *FakeQuerier) UpsertNotificationReportGeneratorLog(_ context.Context, ar return nil } +func (q *FakeQuerier) UpsertNotificationVAPIDKeys(_ context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + q.notificationsPushVAPIDPublicKey = arg.VapidPublicKey + q.notificationsPushVAPIDPrivateKey = arg.VapidPrivateKey + return nil +} + func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 849de4d2d3dff..8ea61686f4fd2 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -284,6 +284,20 @@ func (m queryMetricsStore) DeleteLicense(ctx context.Context, id int32) (int32, return licenseID, err } +func (m queryMetricsStore) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { + start := time.Now() + r0 := m.s.DeleteNotificationPushSubscriptionByEndpoint(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteNotificationPushSubscriptionByEndpoint").Observe(time.Since(start).Seconds()) + return r0 +} + +func (m queryMetricsStore) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteNotificationPushSubscriptions(ctx, ids) + m.queryLatencies.WithLabelValues("DeleteNotificationPushSubscriptions").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteOAuth2ProviderAppByID(ctx, id) @@ -886,6 +900,13 @@ func (m queryMetricsStore) GetNotificationMessagesByStatus(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationPushSubscriptionsByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("GetNotificationPushSubscriptionsByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { start := time.Now() r0, r1 := m.s.GetNotificationReportGeneratorLogByTemplate(ctx, arg) @@ -907,6 +928,13 @@ func (m queryMetricsStore) GetNotificationTemplatesByKind(ctx context.Context, k return r0, r1 } +func (m queryMetricsStore) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationVAPIDKeys(ctx) + m.queryLatencies.WithLabelValues("GetNotificationVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetNotificationsSettings(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetNotificationsSettings(ctx) @@ -1971,6 +1999,13 @@ func (m queryMetricsStore) InsertMissingGroups(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { + start := time.Now() + r0, r1 := m.s.InsertNotificationPushSubscription(ctx, arg) + m.queryLatencies.WithLabelValues("InsertNotificationPushSubscription").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.InsertOAuth2ProviderApp(ctx, arg) @@ -2923,6 +2958,13 @@ func (m queryMetricsStore) UpsertNotificationReportGeneratorLog(ctx context.Cont return r0 } +func (m queryMetricsStore) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { + start := time.Now() + r0 := m.s.UpsertNotificationVAPIDKeys(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertNotificationVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpsertNotificationsSettings(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertNotificationsSettings(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 52c26f4c365a6..5ac331b66b50b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -446,6 +446,34 @@ func (mr *MockStoreMockRecorder) DeleteLicense(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLicense", reflect.TypeOf((*MockStore)(nil).DeleteLicense), ctx, id) } +// DeleteNotificationPushSubscriptionByEndpoint mocks base method. +func (m *MockStore) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNotificationPushSubscriptionByEndpoint", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNotificationPushSubscriptionByEndpoint indicates an expected call of DeleteNotificationPushSubscriptionByEndpoint. +func (mr *MockStoreMockRecorder) DeleteNotificationPushSubscriptionByEndpoint(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationPushSubscriptionByEndpoint", reflect.TypeOf((*MockStore)(nil).DeleteNotificationPushSubscriptionByEndpoint), ctx, arg) +} + +// DeleteNotificationPushSubscriptions mocks base method. +func (m *MockStore) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNotificationPushSubscriptions", ctx, ids) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNotificationPushSubscriptions indicates an expected call of DeleteNotificationPushSubscriptions. +func (mr *MockStoreMockRecorder) DeleteNotificationPushSubscriptions(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationPushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteNotificationPushSubscriptions), ctx, ids) +} + // DeleteOAuth2ProviderAppByID mocks base method. func (m *MockStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -1792,6 +1820,21 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), ctx, arg) } +// GetNotificationPushSubscriptionsByUserID mocks base method. +func (m *MockStore) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationPushSubscriptionsByUserID", ctx, userID) + ret0, _ := ret[0].([]database.NotificationPushSubscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationPushSubscriptionsByUserID indicates an expected call of GetNotificationPushSubscriptionsByUserID. +func (mr *MockStoreMockRecorder) GetNotificationPushSubscriptionsByUserID(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationPushSubscriptionsByUserID", reflect.TypeOf((*MockStore)(nil).GetNotificationPushSubscriptionsByUserID), ctx, userID) +} + // GetNotificationReportGeneratorLogByTemplate mocks base method. func (m *MockStore) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (database.NotificationReportGeneratorLog, error) { m.ctrl.T.Helper() @@ -1837,6 +1880,21 @@ func (mr *MockStoreMockRecorder) GetNotificationTemplatesByKind(ctx, kind any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplatesByKind", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplatesByKind), ctx, kind) } +// GetNotificationVAPIDKeys mocks base method. +func (m *MockStore) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationVAPIDKeys", ctx) + ret0, _ := ret[0].(database.GetNotificationVAPIDKeysRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationVAPIDKeys indicates an expected call of GetNotificationVAPIDKeys. +func (mr *MockStoreMockRecorder) GetNotificationVAPIDKeys(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetNotificationVAPIDKeys), ctx) +} + // GetNotificationsSettings mocks base method. func (m *MockStore) GetNotificationsSettings(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -4157,6 +4215,21 @@ func (mr *MockStoreMockRecorder) InsertMissingGroups(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), ctx, arg) } +// InsertNotificationPushSubscription mocks base method. +func (m *MockStore) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertNotificationPushSubscription", ctx, arg) + ret0, _ := ret[0].(database.NotificationPushSubscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertNotificationPushSubscription indicates an expected call of InsertNotificationPushSubscription. +func (mr *MockStoreMockRecorder) InsertNotificationPushSubscription(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertNotificationPushSubscription", reflect.TypeOf((*MockStore)(nil).InsertNotificationPushSubscription), ctx, arg) +} + // InsertOAuth2ProviderApp mocks base method. func (m *MockStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -6159,6 +6232,20 @@ func (mr *MockStoreMockRecorder) UpsertNotificationReportGeneratorLog(ctx, arg a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationReportGeneratorLog", reflect.TypeOf((*MockStore)(nil).UpsertNotificationReportGeneratorLog), ctx, arg) } +// UpsertNotificationVAPIDKeys mocks base method. +func (m *MockStore) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertNotificationVAPIDKeys", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertNotificationVAPIDKeys indicates an expected call of UpsertNotificationVAPIDKeys. +func (mr *MockStoreMockRecorder) UpsertNotificationVAPIDKeys(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationVAPIDKeys", reflect.TypeOf((*MockStore)(nil).UpsertNotificationVAPIDKeys), ctx, arg) +} + // UpsertNotificationsSettings mocks base method. func (m *MockStore) UpsertNotificationsSettings(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index caa699ad9c04d..296db2114e852 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -996,6 +996,15 @@ CREATE TABLE notification_preferences ( updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); +CREATE TABLE notification_push_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + endpoint text NOT NULL, + endpoint_p256dh_key text NOT NULL, + endpoint_auth_key text NOT NULL +); + CREATE TABLE notification_report_generator_logs ( notification_template_id uuid NOT NULL, last_generated_at timestamp with time zone NOT NULL @@ -2173,6 +2182,9 @@ ALTER TABLE ONLY notification_messages ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); +ALTER TABLE ONLY notification_push_subscriptions + ADD CONSTRAINT notification_push_subscriptions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); @@ -2637,6 +2649,9 @@ ALTER TABLE ONLY notification_preferences ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY notification_push_subscriptions + ADD CONSTRAINT notification_push_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 95a491b670993..69645c335a417 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -22,6 +22,7 @@ const ( ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyNotificationPushSubscriptionsUserID ForeignKeyConstraint = "notification_push_subscriptions_user_id_fkey" // ALTER TABLE ONLY notification_push_subscriptions ADD CONSTRAINT notification_push_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000301_push_notifications.down.sql b/coderd/database/migrations/000301_push_notifications.down.sql new file mode 100644 index 0000000000000..ae3c8c72a9b0b --- /dev/null +++ b/coderd/database/migrations/000301_push_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notification_push_subscriptions; + diff --git a/coderd/database/migrations/000301_push_notifications.up.sql b/coderd/database/migrations/000301_push_notifications.up.sql new file mode 100644 index 0000000000000..61303663dfa55 --- /dev/null +++ b/coderd/database/migrations/000301_push_notifications.up.sql @@ -0,0 +1,13 @@ +-- notification_push_subscriptions is a table that stores push notification +-- subscriptions for users. These are acquired via the Push API in the browser. +CREATE TABLE IF NOT EXISTS notification_push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- endpoint is called by coderd to send a push notification to the user. + endpoint TEXT NOT NULL, + -- endpoint_p256dh_key is the public key for the endpoint. + endpoint_p256dh_key TEXT NOT NULL, + -- endpoint_auth_key is the authentication key for the endpoint. + endpoint_auth_key TEXT NOT NULL +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 1cf136e364eaa..10d075c8d127e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2680,6 +2680,15 @@ type NotificationPreference struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +type NotificationPushSubscription struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Endpoint string `db:"endpoint" json:"endpoint"` + EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"` + EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` +} + // Log of generated reports for users. type NotificationReportGeneratorLog struct { NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b12301eac343f..fe333d9e9b79a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -78,6 +78,8 @@ type sqlcQuerier interface { DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error DeleteLicense(ctx context.Context, id int32) (int32, error) + DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg DeleteNotificationPushSubscriptionByEndpointParams) error + DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error @@ -198,10 +200,12 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) + GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]NotificationPushSubscription, error) // Fetch the notification report generator log indicating recent activity. GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (NotificationReportGeneratorLog, error) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) + GetNotificationVAPIDKeys(ctx context.Context) (GetNotificationVAPIDKeysRow, error) GetNotificationsSettings(ctx context.Context) (string, error) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) @@ -425,6 +429,7 @@ type sqlcQuerier interface { // values for avatar, display name, and quota allowance (all zero values). // If the name conflicts, do nothing. InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) + InsertNotificationPushSubscription(ctx context.Context, arg InsertNotificationPushSubscriptionParams) (NotificationPushSubscription, error) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) InsertOAuth2ProviderAppCode(ctx context.Context, arg InsertOAuth2ProviderAppCodeParams) (OAuth2ProviderAppCode, error) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) @@ -580,6 +585,7 @@ type sqlcQuerier interface { UpsertLogoURL(ctx context.Context, value string) error // Insert or update notification report generator logs with recent activity. UpsertNotificationReportGeneratorLog(ctx context.Context, arg UpsertNotificationReportGeneratorLogParams) error + UpsertNotificationVAPIDKeys(ctx context.Context, arg UpsertNotificationVAPIDKeysParams) error UpsertNotificationsSettings(ctx context.Context, value string) error UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error UpsertOAuthSigningKey(ctx context.Context, value string) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index aeeae6591ecc7..124311a9261d4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3988,6 +3988,31 @@ func (q *sqlQuerier) BulkMarkNotificationMessagesSent(ctx context.Context, arg B return result.RowsAffected() } +const deleteNotificationPushSubscriptionByEndpoint = `-- name: DeleteNotificationPushSubscriptionByEndpoint :exec +DELETE FROM notification_push_subscriptions +WHERE user_id = $1 AND endpoint = $2 +` + +type DeleteNotificationPushSubscriptionByEndpointParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Endpoint string `db:"endpoint" json:"endpoint"` +} + +func (q *sqlQuerier) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg DeleteNotificationPushSubscriptionByEndpointParams) error { + _, err := q.db.ExecContext(ctx, deleteNotificationPushSubscriptionByEndpoint, arg.UserID, arg.Endpoint) + return err +} + +const deleteNotificationPushSubscriptions = `-- name: DeleteNotificationPushSubscriptions :exec +DELETE FROM notification_push_subscriptions +WHERE id = ANY($1::uuid[]) +` + +func (q *sqlQuerier) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteNotificationPushSubscriptions, pq.Array(ids)) + return err +} + const deleteOldNotificationMessages = `-- name: DeleteOldNotificationMessages :exec DELETE FROM notification_messages @@ -4140,6 +4165,42 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge return items, nil } +const getNotificationPushSubscriptionsByUserID = `-- name: GetNotificationPushSubscriptionsByUserID :many +SELECT id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key +FROM notification_push_subscriptions +WHERE user_id = $1::uuid +` + +func (q *sqlQuerier) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]NotificationPushSubscription, error) { + rows, err := q.db.QueryContext(ctx, getNotificationPushSubscriptionsByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationPushSubscription + for rows.Next() { + var i NotificationPushSubscription + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.Endpoint, + &i.EndpointP256dhKey, + &i.EndpointAuthKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getNotificationReportGeneratorLogByTemplate = `-- name: GetNotificationReportGeneratorLogByTemplate :one SELECT notification_template_id, last_generated_at @@ -4255,6 +4316,42 @@ func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID return items, nil } +const insertNotificationPushSubscription = `-- name: InsertNotificationPushSubscription :one +INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key +` + +type InsertNotificationPushSubscriptionParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Endpoint string `db:"endpoint" json:"endpoint"` + EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"` + EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` +} + +func (q *sqlQuerier) InsertNotificationPushSubscription(ctx context.Context, arg InsertNotificationPushSubscriptionParams) (NotificationPushSubscription, error) { + row := q.db.QueryRowContext(ctx, insertNotificationPushSubscription, + arg.ID, + arg.UserID, + arg.CreatedAt, + arg.Endpoint, + arg.EndpointP256dhKey, + arg.EndpointAuthKey, + ) + var i NotificationPushSubscription + err := row.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.Endpoint, + &i.EndpointP256dhKey, + &i.EndpointAuthKey, + ) + return i, err +} + const updateNotificationTemplateMethodByID = `-- name: UpdateNotificationTemplateMethodByID :one UPDATE notification_templates SET method = $1::notification_method @@ -8510,6 +8607,24 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) { return value, err } +const getNotificationVAPIDKeys = `-- name: GetNotificationVAPIDKeys :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_public_key'), '') :: text AS vapid_public_key, + COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_private_key'), '') :: text AS vapid_private_key +` + +type GetNotificationVAPIDKeysRow struct { + VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` + VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` +} + +func (q *sqlQuerier) GetNotificationVAPIDKeys(ctx context.Context) (GetNotificationVAPIDKeysRow, error) { + row := q.db.QueryRowContext(ctx, getNotificationVAPIDKeys) + var i GetNotificationVAPIDKeysRow + err := row.Scan(&i.VapidPublicKey, &i.VapidPrivateKey) + return i, err +} + const getNotificationsSettings = `-- name: GetNotificationsSettings :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings @@ -8672,6 +8787,25 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error { return err } +const upsertNotificationVAPIDKeys = `-- name: UpsertNotificationVAPIDKeys :exec +INSERT INTO site_configs (key, value) +VALUES + ('notification_vapid_public_key', $1 :: text), + ('notification_vapid_private_key', $2 :: text) +ON CONFLICT (key) +DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key +` + +type UpsertNotificationVAPIDKeysParams struct { + VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` + VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` +} + +func (q *sqlQuerier) UpsertNotificationVAPIDKeys(ctx context.Context, arg UpsertNotificationVAPIDKeysParams) error { + _, err := q.db.ExecContext(ctx, upsertNotificationVAPIDKeys, arg.VapidPublicKey, arg.VapidPrivateKey) + return err +} + const upsertNotificationsSettings = `-- name: UpsertNotificationsSettings :exec INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings' diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index f2d1a14c3aae7..160f38d08f06f 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -189,3 +189,21 @@ WHERE INSERT INTO notification_report_generator_logs (notification_template_id, last_generated_at) VALUES (@notification_template_id, @last_generated_at) ON CONFLICT (notification_template_id) DO UPDATE set last_generated_at = EXCLUDED.last_generated_at WHERE notification_report_generator_logs.notification_template_id = EXCLUDED.notification_template_id; + +-- name: GetNotificationPushSubscriptionsByUserID :many +SELECT * +FROM notification_push_subscriptions +WHERE user_id = @user_id::uuid; + +-- name: InsertNotificationPushSubscription :one +INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: DeleteNotificationPushSubscriptions :exec +DELETE FROM notification_push_subscriptions +WHERE id = ANY(@ids::uuid[]); + +-- name: DeleteNotificationPushSubscriptionByEndpoint :exec +DELETE FROM notification_push_subscriptions +WHERE user_id = @user_id AND endpoint = @endpoint; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index ab9fda7969cea..ba0ce7b86e2a6 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -131,3 +131,16 @@ SET value = CASE ELSE 'false' END WHERE site_configs.key = 'oauth2_github_default_eligible'; + +-- name: UpsertNotificationVAPIDKeys :exec +INSERT INTO site_configs (key, value) +VALUES + ('notification_vapid_public_key', @vapid_public_key :: text), + ('notification_vapid_private_key', @vapid_private_key :: text) +ON CONFLICT (key) +DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key; + +-- name: GetNotificationVAPIDKeys :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_public_key'), '') :: text AS vapid_public_key, + COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_private_key'), '') :: text AS vapid_private_key; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index a30723882a302..90e3fc79c333b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -27,6 +27,7 @@ const ( UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); + UniqueNotificationPushSubscriptionsPkey UniqueConstraint = "notification_push_subscriptions_pkey" // ALTER TABLE ONLY notification_push_subscriptions ADD CONSTRAINT notification_push_subscriptions_pkey PRIMARY KEY (id); UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); diff --git a/coderd/notifications.go b/coderd/notifications.go index 670f3625f41bc..1c26104f989bb 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -3,8 +3,11 @@ package coderd import ( "bytes" "encoding/json" + "io" "net/http" + "strconv" + "github.com/SherClockHolmes/webpush-go" "github.com/google/uuid" "cdr.dev/slog" @@ -12,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" @@ -323,6 +327,142 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } +// @Summary Create user push notification subscription +// @ID create-user-push-notification-subscription +// @Security CoderSessionToken +// @Accept json +// @Tags Notifications +// @Param request body codersdk.PushNotificationSubscription true "Push notification subscription" +// @Param user path string true "User ID, name, or me" +// @Router /users/{user}/notifications/push/subscription [post] +// @Success 204 +func (api *API) postUserPushNotificationSubscription(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + var req codersdk.PushNotificationSubscription + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + notificationJSON, err := json.Marshal(codersdk.PushNotification{ + Title: "It's working!", + Body: "You've subscribed to push notifications.", + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal notification", + Detail: err.Error(), + }) + return + } + + // Before inserting the subscription into the database, we send a test notification + // to ensure the subscription is valid. + resp, err := webpush.SendNotificationWithContext(r.Context(), notificationJSON, &webpush.Subscription{ + Endpoint: req.Endpoint, + Keys: webpush.Keys{ + Auth: req.AuthKey, + P256dh: req.P256DHKey, + }, + }, &webpush.Options{ + VAPIDPublicKey: api.PushNotifier.PublicKey(), + VAPIDPrivateKey: api.PushNotifier.PrivateKey(), + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send notification", + Detail: err.Error(), + }) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send notification. Status code: " + strconv.Itoa(resp.StatusCode), + Detail: string(body), + }) + return + } + + _, err = api.Database.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + UserID: user.ID, + Endpoint: req.Endpoint, + EndpointAuthKey: req.AuthKey, + EndpointP256dhKey: req.P256DHKey, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert push notification subscription.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// @Summary Delete user push notification subscription +// @ID delete-user-push-notification-subscription +// @Security CoderSessionToken +// @Accept json +// @Tags Notifications +// @Param request body codersdk.DeletePushNotificationSubscription true "Push notification subscription" +// @Param user path string true "User ID, name, or me" +// @Router /users/{user}/notifications/push/subscription [delete] +// @Success 204 +func (api *API) deleteUserPushNotificationSubscription(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + var req codersdk.DeletePushNotificationSubscription + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + err := api.Database.DeleteNotificationPushSubscriptionByEndpoint(ctx, database.DeleteNotificationPushSubscriptionByEndpointParams{ + UserID: user.ID, + Endpoint: req.Endpoint, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete push notification subscription.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// @Summary Send a test push notification +// @ID send-a-test-push-notification +// @Security CoderSessionToken +// @Tags Notifications +// @Param user path string true "User ID, name, or me" +// @Success 204 +// @Router /users/{user}/notifications/push/test [post] +func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + if err := api.PushNotifier.Dispatch(ctx, user.ID, codersdk.PushNotification{ + Title: "It's working!", + Body: "You've subscribed to push notifications.", + }); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send test notification", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { for _, tmpl := range in { out = append(out, codersdk.NotificationTemplate{ diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go new file mode 100644 index 0000000000000..09a0529b9e535 --- /dev/null +++ b/coderd/notifications/push/push.go @@ -0,0 +1,141 @@ +package push + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "sync" + + "github.com/SherClockHolmes/webpush-go" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" +) + +// New creates a new push manager to dispatch push notifications. +// +// This is *not* integrated into the enqueue system unfortunately. +// That's because the notifications system has a enqueue system, +// and push notifications at time of implementation are being used +// for updates inside of a workspace, which we want to be immediate. +// +// There should be refactor of the core abstraction to merge dispatch +// and queue, and then we can integrate this. +func New(ctx context.Context, log *slog.Logger, db database.Store) (*Notifier, error) { + keys, err := db.GetNotificationVAPIDKeys(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 == "" { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return nil, xerrors.Errorf("generate vapid keys: %w", err) + } + err = db.UpsertNotificationVAPIDKeys(ctx, database.UpsertNotificationVAPIDKeysParams{ + VapidPublicKey: publicKey, + VapidPrivateKey: privateKey, + }) + if err != nil { + return nil, xerrors.Errorf("upsert notification vapid keys: %w", err) + } + keys.VapidPublicKey = publicKey + keys.VapidPrivateKey = privateKey + } + + return &Notifier{ + store: db, + log: log, + VAPIDPublicKey: keys.VapidPublicKey, + VAPIDPrivateKey: keys.VapidPrivateKey, + }, nil +} + +type Notifier struct { + store database.Store + log *slog.Logger + + VAPIDPublicKey string + VAPIDPrivateKey string +} + +func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.PushNotification) error { + subscriptions, err := n.store.GetNotificationPushSubscriptionsByUserID(ctx, userID) + if err != nil { + return xerrors.Errorf("get notification push subscriptions by user ID: %w", err) + } + if len(subscriptions) == 0 { + return nil + } + + notificationJSON, err := json.Marshal(notification) + if err != nil { + return xerrors.Errorf("marshal notification: %w", err) + } + + cleanupSubscriptions := make([]uuid.UUID, 0) + var mu sync.Mutex + var eg errgroup.Group + for _, subscription := range subscriptions { + subscription := subscription + eg.Go(func() error { + n.log.Debug(ctx, "dispatching via push", slog.F("subscription", subscription.Endpoint)) + + resp, err := webpush.SendNotificationWithContext(ctx, notificationJSON, &webpush.Subscription{ + Endpoint: subscription.Endpoint, + Keys: webpush.Keys{ + Auth: subscription.EndpointAuthKey, + P256dh: subscription.EndpointP256dhKey, + }, + }, &webpush.Options{ + VAPIDPublicKey: n.VAPIDPublicKey, + VAPIDPrivateKey: n.VAPIDPrivateKey, + }) + if err != nil { + return xerrors.Errorf("send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusGone { + // The subscription is no longer valid, remove it. + mu.Lock() + cleanupSubscriptions = append(cleanupSubscriptions, subscription.ID) + mu.Unlock() + return nil + } + + // 200, 201, and 202 are common for successful delivery. + if resp.StatusCode > http.StatusAccepted { + // It's likely the subscription failed to deliver for some reason. + body, _ := io.ReadAll(resp.Body) + return xerrors.Errorf("push notification failed with status code %d: %s", resp.StatusCode, string(body)) + } + + return nil + }) + } + + err = eg.Wait() + if err != nil { + return xerrors.Errorf("send push notifications: %w", err) + } + + if len(cleanupSubscriptions) > 0 { + // nolint:gocritic // These are known to be invalid subscriptions. + err = n.store.DeleteNotificationPushSubscriptions(dbauthz.AsSystemRestricted(ctx), cleanupSubscriptions) + if err != nil { + n.log.Error(ctx, "failed to delete stale push subscriptions", slog.Error(err)) + } + } + + return nil +} diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go new file mode 100644 index 0000000000000..30d27144cd1b7 --- /dev/null +++ b/coderd/notifications/push/push_test.go @@ -0,0 +1,252 @@ +package push_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications/push" + "github.com/coder/coder/v2/codersdk" +) + +const validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" +const validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" + +func TestPush(t *testing.T) { + t.Parallel() + + t.Run("SuccessfulDelivery", func(t *testing.T) { + t.Parallel() + manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + userID := uuid.New() + sub, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: uuid.New(), + UserID: userID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.PushNotification{ + Title: "Test Title", + Body: "Test Body", + Actions: []codersdk.PushNotificationAction{ + {Label: "View", URL: "https://coder.com/view"}, + }, + Icon: "workspace", + } + + err = manager.Dispatch(context.Background(), userID, notification) + require.NoError(t, err) + + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Len(t, subscriptions, 1, "One subscription should be returned") + assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") + }) + + t.Run("ExpiredSubscription", func(t *testing.T) { + t.Parallel() + manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusGone) + }) + userID := uuid.New() + subID := uuid.New() + _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: subID, + UserID: userID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.PushNotification{ + Title: "Test Title", + Body: "Test Body", + } + + err = manager.Dispatch(context.Background(), userID, notification) + require.NoError(t, err) + + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Len(t, subscriptions, 0, "No subscriptions should be returned") + }) + + t.Run("FailedDelivery", func(t *testing.T) { + t.Parallel() + manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid request")) + }) + userID := uuid.New() + sub, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: uuid.New(), + UserID: userID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.PushNotification{ + Title: "Test Title", + Body: "Test Body", + } + + err = manager.Dispatch(context.Background(), userID, notification) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid request") + + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Len(t, subscriptions, 1, "One subscription should be returned") + assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") + }) + + t.Run("MultipleSubscriptions", func(t *testing.T) { + t.Parallel() + + var okEndpointCalled bool + var goneEndpointCalled bool + manager, store, serverOKURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + okEndpointCalled = true + w.WriteHeader(http.StatusOK) + }) + + serverGone := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + goneEndpointCalled = true + w.WriteHeader(http.StatusGone) + })) + defer serverGone.Close() + serverGoneURL := serverGone.URL + + // Setup subscriptions pointing to our test servers + userID := uuid.New() + sub1ID := uuid.New() + sub2ID := uuid.New() + + _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: sub1ID, + UserID: userID, + Endpoint: serverOKURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + _, err = store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: sub2ID, + UserID: userID, + Endpoint: serverGoneURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.PushNotification{ + Title: "Test Title", + Body: "Test Body", + Actions: []codersdk.PushNotificationAction{ + {Label: "View", URL: "https://coder.com/view"}, + }, + } + + err = manager.Dispatch(context.Background(), userID, notification) + require.NoError(t, err) + assert.True(t, okEndpointCalled, "The valid endpoint should be called") + assert.True(t, goneEndpointCalled, "The expired endpoint should be called") + + // assert.Len(t, store.deletedIDs, 1, "One subscription should be deleted") + // assert.Contains(t, store.deletedIDs, sub2ID, "The expired subscription should be deleted") + // assert.NotContains(t, store.deletedIDs, sub1ID, "The valid subscription should not be deleted") + }) + + t.Run("NotificationPayload", func(t *testing.T) { + t.Parallel() + var requestReceived bool + manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + requestReceived = true + w.WriteHeader(http.StatusOK) + }) + + userID := uuid.New() + + _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + UserID: userID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + }) + require.NoError(t, err) + + notification := codersdk.PushNotification{ + Title: "Test Notification", + Body: "This is a test notification body", + Actions: []codersdk.PushNotificationAction{ + {Label: "View Workspace", URL: "https://coder.com/workspace/123"}, + {Label: "Cancel", URL: "https://coder.com/cancel"}, + }, + Icon: "workspace-icon", + } + + err = manager.Dispatch(context.Background(), userID, notification) + require.NoError(t, err) + assert.True(t, requestReceived, "The push notification request should have been received by the server") + }) + + t.Run("NoSubscriptions", func(t *testing.T) { + t.Parallel() + manager, store, _ := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + userID := uuid.New() + notification := codersdk.PushNotification{ + Title: "Test Title", + Body: "Test Body", + } + + err := manager.Dispatch(context.Background(), userID, notification) + require.NoError(t, err) + + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Empty(t, subscriptions, "No subscriptions should be returned") + }) +} + +// setupPushTest creates a common test setup for push notification tests +func setupPushTest(t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (*push.Notifier, database.Store, string) { + 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 := push.New(context.Background(), &logger, db) + require.NoError(t, err) + + return manager, db, server.URL +} diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index bae8b8827fe79..df9e66c17d26c 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "net/http" + "net/http/httptest" "slices" "testing" @@ -376,3 +377,59 @@ func TestNotificationTest(t *testing.T) { require.Len(t, sent, 0) }) } + +const validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" +const validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" + +func TestPushNotificationSubscription(t *testing.T) { + t.Parallel() + + t.Run("Create", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + notificationSent := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + notificationSent = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + + err := client.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.NoError(t, err) + require.True(t, notificationSent) + }) + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := client.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.NoError(t, err) + + err = client.DeleteNotificationPushSubscription(ctx, "me", codersdk.DeletePushNotificationSubscription{ + Endpoint: server.URL, + }) + require.NoError(t, err) + }) +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 0800ab9b25260..c420281c375f9 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -155,6 +155,15 @@ var ( Type: "notification_preference", } + // ResourceNotificationPushSubscription + // Valid Actions + // - "ActionCreate" :: create notification push subscriptions + // - "ActionDelete" :: delete notification push subscriptions + // - "ActionRead" :: read notification push subscriptions + ResourceNotificationPushSubscription = Object{ + Type: "notification_push_subscription", + } + // ResourceNotificationTemplate // Valid Actions // - "ActionRead" :: read notification templates @@ -354,6 +363,7 @@ func AllResources() []Objecter { ResourceLicense, ResourceNotificationMessage, ResourceNotificationPreference, + ResourceNotificationPushSubscription, ResourceNotificationTemplate, ResourceOauth2App, ResourceOauth2AppCodeToken, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 15bebb149f34d..ad65efaa62c47 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -280,6 +280,13 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: actDef("update notification preferences"), }, }, + "notification_push_subscription": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef("create notification push subscriptions"), + ActionRead: actDef("read notification push subscriptions"), + ActionDelete: actDef("delete notification push subscriptions"), + }, + }, "inbox_notification": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create inbox notifications"), diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5ba0607b4a6d1..f5f60dbbdb674 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3147,6 +3147,9 @@ type BuildInfoResponse struct { // DeploymentID is the unique identifier for this deployment. DeploymentID string `json:"deployment_id"` + + // PushNotificationsPublicKey is the public key for push notifications. + PushNotificationsPublicKey string `json:"push_notifications_public_key"` } type WorkspaceProxyBuildInfo struct { diff --git a/codersdk/notifications.go b/codersdk/notifications.go index ac5fe8e60bce1..904105e9c98f3 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -212,4 +212,56 @@ type UpdateNotificationTemplateMethod struct { type UpdateUserNotificationPreferences struct { TemplateDisabledMap map[string]bool `json:"template_disabled_map"` + PushSubscription string `json:"push_subscription,omitempty"` +} + +type PushNotificationAction struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type PushNotification struct { + Icon string `json:"icon"` + Title string `json:"title"` + Body string `json:"body"` + Actions []PushNotificationAction `json:"actions"` +} + +type PushNotificationSubscription struct { + Endpoint string `json:"endpoint"` + AuthKey string `json:"auth_key"` + P256DHKey string `json:"p256dh_key"` +} + +type DeletePushNotificationSubscription struct { + Endpoint string `json:"endpoint"` +} + +// CreateNotificationPushSubscription creates a push notification subscription for a given user. +func (c *Client) CreateNotificationPushSubscription(ctx context.Context, user string, req PushNotificationSubscription) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// DeleteNotificationPushSubscription deletes a push notification subscription for a given user. +// Think of this as an unsubscribe, but for a specific push notification subscription. +func (c *Client) DeleteNotificationPushSubscription(ctx context.Context, user string, req DeletePushNotificationSubscription) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil } diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 4cf10ea69417e..b4137635ec5dd 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -21,6 +21,7 @@ const ( ResourceLicense RBACResource = "license" ResourceNotificationMessage RBACResource = "notification_message" ResourceNotificationPreference RBACResource = "notification_preference" + ResourceNotificationPushSubscription RBACResource = "notification_push_subscription" ResourceNotificationTemplate RBACResource = "notification_template" ResourceOauth2App RBACResource = "oauth2_app" ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token" @@ -80,6 +81,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationMessage: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceNotificationPreference: {ActionRead, ActionUpdate}, + ResourceNotificationPushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationTemplate: {ActionRead, ActionUpdate}, ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead}, diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 25ecf30311478..2339fc17d0800 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -58,6 +58,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "deployment_id": "string", "external_url": "string", "provisioner_api_version": "string", + "push_notifications_public_key": "string", "telemetry": true, "upgrade_message": "string", "version": "string", diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index e2af6342aabcf..d7e0bc473706b 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -197,6 +197,7 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | +| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -362,6 +363,7 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | +| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -527,6 +529,7 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | +| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -661,6 +664,7 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | +| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -1017,6 +1021,7 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | +| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 09890d3b17864..188f326dc2509 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -463,6 +463,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/notifications/preferenc ```json { + "push_subscription": "string", "template_disabled_map": { "property1": true, "property2": true diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index dd6ad218d3617..ab4203746d137 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -961,6 +961,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "deployment_id": "string", "external_url": "string", "provisioner_api_version": "string", + "push_notifications_public_key": "string", "telemetry": true, "upgrade_message": "string", "version": "string", @@ -970,17 +971,18 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | -| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | -| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | -| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | -| `provisioner_api_version` | string | false | | Provisioner api version is the current version of the Provisioner API | -| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | -| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | -| `version` | string | false | | Version returns the semantic version of the build. | -| `workspace_proxy` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | +| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | +| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | +| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | +| `provisioner_api_version` | string | false | | Provisioner api version is the current version of the Provisioner API | +| `push_notifications_public_key` | string | false | | Push notifications public key is the public key for push notifications. | +| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | +| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | +| `version` | string | false | | Version returns the semantic version of the build. | +| `workspace_proxy` | boolean | false | | | ## codersdk.BuildReason @@ -1755,6 +1757,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `allow_path_app_sharing` | boolean | false | | | | `allow_path_app_site_owner_access` | boolean | false | | | +## codersdk.DeletePushNotificationSubscription + +```json +{ + "endpoint": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|--------|----------|--------------|-------------| +| `endpoint` | string | false | | | + ## codersdk.DeleteWorkspaceAgentPortShareRequest ```json @@ -5243,6 +5259,24 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `unhealthy` | | `unregistered` | +## codersdk.PushNotificationSubscription + +```json +{ + "auth_key": "string", + "endpoint": "string", + "p256dh_key": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------|----------|--------------|-------------| +| `auth_key` | string | false | | | +| `endpoint` | string | false | | | +| `p256dh_key` | string | false | | | + ## codersdk.PutExtendWorkspaceRequest ```json @@ -5331,6 +5365,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `license` | | `notification_message` | | `notification_preference` | +| `notification_push_subscription` | | `notification_template` | | `oauth2_app` | | `oauth2_app_code_token` | @@ -6848,6 +6883,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { + "push_subscription": "string", "template_disabled_map": { "property1": true, "property2": true @@ -6859,6 +6895,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | Name | Type | Required | Restrictions | Description | |-------------------------|---------|----------|--------------|-------------| +| `push_subscription` | string | false | | | | `template_disabled_map` | object | false | | | | » `[any property]` | boolean | false | | | diff --git a/go.mod b/go.mod index 31551d77e4436..92fa4ec026147 100644 --- a/go.mod +++ b/go.mod @@ -471,9 +471,12 @@ require ( require github.com/coder/clistat v1.0.0 +require github.com/SherClockHolmes/webpush-go v1.4.0 + require ( github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) diff --git a/go.sum b/go.sum index 1bf39d2803afb..6332bbf4541c8 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= @@ -452,6 +454,8 @@ github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTH github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -1062,7 +1066,11 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= @@ -1074,6 +1082,9 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -1087,6 +1098,9 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= @@ -1098,6 +1112,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1136,17 +1154,26 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1158,7 +1185,10 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -1170,6 +1200,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b042735357ab0..33609e4765638 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2371,6 +2371,28 @@ class ApiMethods { await this.axios.post("/api/v2/notifications/test"); }; + createNotificationPushSubscription = async ( + userId: string, + req: TypesGen.PushNotificationSubscription, + ) => { + await this.axios.post( + `/api/v2/users/${userId}/notifications/push/subscription`, + req, + ); + }; + + deleteNotificationPushSubscription = async ( + userId: string, + req: TypesGen.DeletePushNotificationSubscription, + ) => { + await this.axios.delete( + `/api/v2/users/${userId}/notifications/push/subscription`, + { + data: req, + }, + ); + }; + requestOneTimePassword = async ( req: TypesGen.RequestOneTimePasscodeRequest, ) => { diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 8442b110ae028..83f95ea75ad38 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -84,6 +84,11 @@ export const RBACResourceActions: Partial< read: "read notification preferences", update: "update notification preferences", }, + notification_push_subscription: { + create: "create notification push subscriptions", + delete: "delete notification push subscriptions", + read: "read notification push subscriptions", + }, notification_template: { read: "read notification templates", update: "update notification templates", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fe966d7b5ddd2..8c8b237fecb0f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -263,6 +263,7 @@ export interface BuildInfoResponse { readonly provisioner_api_version: string; readonly upgrade_message: string; readonly deployment_id: string; + readonly push_notifications_public_key: string; } // From codersdk/workspacebuilds.go @@ -597,6 +598,11 @@ export interface DatabaseReport extends BaseReport { readonly threshold_ms: number; } +// From codersdk/notifications.go +export interface DeletePushNotificationSubscription { + readonly endpoint: string; +} + // From codersdk/workspaceagentportshare.go export interface DeleteWorkspaceAgentPortShareRequest { readonly agent_name: string; @@ -1896,6 +1902,27 @@ export const ProxyHealthStatuses: ProxyHealthStatus[] = [ "unregistered", ]; +// From codersdk/notifications.go +export interface PushNotification { + readonly icon: string; + readonly title: string; + readonly body: string; + readonly actions: readonly PushNotificationAction[]; +} + +// From codersdk/notifications.go +export interface PushNotificationAction { + readonly label: string; + readonly url: string; +} + +// From codersdk/notifications.go +export interface PushNotificationSubscription { + readonly endpoint: string; + readonly auth_key: string; + readonly p256dh_key: string; +} + // From codersdk/workspaces.go export interface PutExtendWorkspaceRequest { readonly deadline: string; @@ -1960,6 +1987,7 @@ export type RBACResource = | "license" | "notification_message" | "notification_preference" + | "notification_push_subscription" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" @@ -1997,6 +2025,7 @@ export const RBACResources: RBACResource[] = [ "license", "notification_message", "notification_preference", + "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -2771,6 +2800,7 @@ export interface UpdateUserAppearanceSettingsRequest { // From codersdk/notifications.go export interface UpdateUserNotificationPreferences { readonly template_disabled_map: Record; + readonly push_subscription?: string; } // From codersdk/users.go diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/usePushNotifications.ts new file mode 100644 index 0000000000000..cd7501be133c7 --- /dev/null +++ b/site/src/contexts/usePushNotifications.ts @@ -0,0 +1,108 @@ +import { API } from "api/api"; +import { buildInfo } from "api/queries/buildInfo"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { useEffect, useState } from "react"; +import { useQuery } from "react-query"; + +interface PushNotifications { + readonly subscribed: boolean; + readonly loading: boolean; + + subscribe(): Promise; + unsubscribe(): Promise; +} + +export const usePushNotifications = (): PushNotifications => { + const { metadata } = useEmbeddedMetadata(); + const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + + const [subscribed, setSubscribed] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check if browser supports push notifications + if (!("Notification" in window) || !("serviceWorker" in navigator)) { + setSubscribed(false); + setLoading(false); + return; + } + + const checkSubscription = async () => { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + setSubscribed(!!subscription); + } catch (error) { + console.error("Error checking push subscription:", error); + setSubscribed(false); + } finally { + setLoading(false); + } + }; + + checkSubscription(); + }, []); + + const subscribe = async (): Promise => { + try { + setLoading(true); + const registration = await navigator.serviceWorker.ready; + + console.log( + "BUILD INFO", + buildInfoQuery.data?.push_notifications_public_key, + ); + + // Note: You'd typically get this key from your server + const vapidPublicKey = buildInfoQuery.data?.push_notifications_public_key; + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey, + }); + const json = subscription.toJSON(); + if (!json.keys || !json.endpoint) { + throw new Error("No keys or endpoint found"); + } + + await API.createNotificationPushSubscription("me", { + endpoint: json.endpoint, + auth_key: json.keys.auth, + p256dh_key: json.keys.p256dh, + }); + + // Send subscription to your server here + setSubscribed(true); + } catch (error) { + console.error("Subscription failed:", error); + throw error; + } finally { + setLoading(false); + } + }; + + const unsubscribe = async (): Promise => { + try { + setLoading(true); + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await subscription.unsubscribe(); + setSubscribed(false); + } + } catch (error) { + console.error("Unsubscription failed:", error); + throw error; + } finally { + setLoading(false); + } + }; + + return { + subscribed, + loading: loading || buildInfoQuery.isLoading, + subscribe, + unsubscribe, + }; +}; diff --git a/site/src/index.tsx b/site/src/index.tsx index aef10d6c64f4d..85d66b9833d3e 100644 --- a/site/src/index.tsx +++ b/site/src/index.tsx @@ -14,5 +14,10 @@ if (element === null) { throw new Error("root element is null"); } +// The service worker handles push notifications. +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/serviceWorker.js"); +} + const root = createRoot(element); root.render(); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 204828c2fd8ac..a19a8ecb3dc06 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -3,6 +3,7 @@ import type * as TypesGen from "api/typesGenerated"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { ProxyContextValue } from "contexts/ProxyContext"; +import { usePushNotifications } from "contexts/usePushNotifications"; import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox"; import type { FC } from "react"; import { NavLink, useLocation } from "react-router-dom"; @@ -43,6 +44,11 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { + const { subscribed, loading, subscribe, unsubscribe } = + usePushNotifications(); + + console.log("HERE"); + return (
@@ -55,7 +61,13 @@ export const NavbarView: FC = ({ -
+ {subscribed ? ( + + ) : ( + + )} + +
{proxyContextValue && (
diff --git a/site/src/serviceWorker.ts b/site/src/serviceWorker.ts new file mode 100644 index 0000000000000..a9d050d77c23c --- /dev/null +++ b/site/src/serviceWorker.ts @@ -0,0 +1,56 @@ +/// + +import { + type PushNotification, + PushNotificationAction, +} from "api/typesGenerated"; + +// @ts-ignore +declare const self: ServiceWorkerGlobalScope; + +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", (event) => { + if (!event.data) { + return; + } + + let payload: PushNotification; + try { + payload = event.data.json(); + } catch (e) { + return; + } + + console.log("PAYLOAD", payload); + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body || "", + icon: payload.icon || "/favicon.ico", + // actions: payload.actions.map((action: PushNotificationAction) => ({ + // title: action.title, + // action: action.url, + // })) || [], + }), + ); +}); + +// Handle notification click +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + // If a link is provided, navigate to it + const data = event.notification.data; + // if (data && data.url) { + // event.waitUntil( + // clients.openWindow(data.url) + // ); + // } +}); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d956e09957c7e..c0424a43aa434 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -227,6 +227,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { workspace_proxy: false, upgrade_message: "My custom upgrade message", deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8", + push_notifications_public_key: "fake-public-key", telemetry: true, }; diff --git a/site/vite.config.mts b/site/vite.config.mts index 436565c491240..89c5c924a8563 100644 --- a/site/vite.config.mts +++ b/site/vite.config.mts @@ -1,5 +1,6 @@ import * as path from "node:path"; import react from "@vitejs/plugin-react"; +import { buildSync } from "esbuild"; import { visualizer } from "rollup-plugin-visualizer"; import { type PluginOption, defineConfig } from "vite"; import checker from "vite-plugin-checker"; @@ -28,6 +29,19 @@ export default defineConfig({ emptyOutDir: false, // 'hidden' works like true except that the corresponding sourcemap comments in the bundled files are suppressed sourcemap: "hidden", + rollupOptions: { + input: { + index: path.resolve(__dirname, "./index.html"), + serviceWorker: path.resolve(__dirname, "./src/serviceWorker.ts"), + }, + output: { + entryFileNames: (chunkInfo) => { + return chunkInfo.name === "serviceWorker" + ? "[name].js" + : "assets/[name]-[hash].js"; + }, + }, + }, }, define: { "process.env": { @@ -89,6 +103,10 @@ export default defineConfig({ target: process.env.CODER_HOST || "http://localhost:3000", secure: process.env.NODE_ENV === "production", }, + "/serviceWorker.js": { + target: process.env.CODER_HOST || "http://localhost:3000", + secure: process.env.NODE_ENV === "production", + }, }, allowedHosts: true, }, From bc02200c6ef7465334a680561a7e7df64609fba3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 14:00:25 +0000 Subject: [PATCH 02/40] fix migration number --- ..._notifications.down.sql => 000310_push_notifications.down.sql} | 0 ...push_notifications.up.sql => 000310_push_notifications.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000301_push_notifications.down.sql => 000310_push_notifications.down.sql} (100%) rename coderd/database/migrations/{000301_push_notifications.up.sql => 000310_push_notifications.up.sql} (100%) diff --git a/coderd/database/migrations/000301_push_notifications.down.sql b/coderd/database/migrations/000310_push_notifications.down.sql similarity index 100% rename from coderd/database/migrations/000301_push_notifications.down.sql rename to coderd/database/migrations/000310_push_notifications.down.sql diff --git a/coderd/database/migrations/000301_push_notifications.up.sql b/coderd/database/migrations/000310_push_notifications.up.sql similarity index 100% rename from coderd/database/migrations/000301_push_notifications.up.sql rename to coderd/database/migrations/000310_push_notifications.up.sql From 2ecae9d31fa79c2a1788e52a9b35668d77dca9c8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 14:22:54 +0000 Subject: [PATCH 03/40] make fmt --- coderd/notifications/push/push_test.go | 6 ++++-- coderd/notifications_test.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go index 30d27144cd1b7..4c1e947c167d9 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/notifications/push/push_test.go @@ -19,8 +19,10 @@ import ( "github.com/coder/coder/v2/codersdk" ) -const validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" -const validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +const ( + validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" + validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +) func TestPush(t *testing.T) { t.Parallel() diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index df9e66c17d26c..1c2c0516418d5 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -378,8 +378,10 @@ func TestNotificationTest(t *testing.T) { }) } -const validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" -const validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +const ( + validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" + validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +) func TestPushNotificationSubscription(t *testing.T) { t.Parallel() From 84e3acefbad4130ebf1044d99209aeb1d34af82d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 17:07:51 +0000 Subject: [PATCH 04/40] fix tests --- coderd/notifications/push/push.go | 3 +-- coderd/notifications_test.go | 43 ++++++++++--------------------- coderd/rbac/roles_test.go | 10 +++++++ 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index 09a0529b9e535..73393023ff816 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -27,8 +27,7 @@ import ( // and push notifications at time of implementation are being used // for updates inside of a workspace, which we want to be immediate. // -// There should be refactor of the core abstraction to merge dispatch -// and queue, and then we can integrate this. +// See: https://github.com/coder/internal/issues/528 func New(ctx context.Context, log *slog.Logger, db database.Store) (*Notifier, error) { keys, err := db.GetNotificationVAPIDKeys(ctx) if err != nil { diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 1c2c0516418d5..4dab1be470df7 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -379,6 +379,8 @@ func TestNotificationTest(t *testing.T) { } const ( + // These are valid keys for a push notification subscription. + // DO NOT REUSE THESE IN ANY REAL CODE. validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" ) @@ -386,52 +388,33 @@ const ( func TestPushNotificationSubscription(t *testing.T) { t.Parallel() - t.Run("Create", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - - notificationSent := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - notificationSent = true - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - client := coderdtest.New(t, nil) - coderdtest.CreateFirstUser(t, client) - - err := client.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ - Endpoint: server.URL, - AuthKey: validEndpointAuthKey, - P256DHKey: validEndpointP256dhKey, - }) - require.NoError(t, err) - require.True(t, notificationSent) - }) - t.Run("Delete", func(t *testing.T) { + t.Run("CRUD", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) client := coderdtest.New(t, nil) - coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + handlerCalled := make(chan bool, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) + handlerCalled <- true })) defer server.Close() - err := client.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ + err := memberClient.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ Endpoint: server.URL, AuthKey: validEndpointAuthKey, P256DHKey: validEndpointP256dhKey, }) - require.NoError(t, err) + require.NoError(t, err, "create notification push subscription") + require.True(t, <-handlerCalled, "handler should have been called") - err = client.DeleteNotificationPushSubscription(ctx, "me", codersdk.DeletePushNotificationSubscription{ + err = memberClient.DeleteNotificationPushSubscription(ctx, "me", codersdk.DeletePushNotificationSubscription{ Endpoint: server.URL, }) - require.NoError(t, err) + require.NoError(t, err, "delete notification push subscription") }) } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index be03ae66eb02a..340373da72d00 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -713,6 +713,16 @@ func TestRolePermissions(t *testing.T) { }, }, }, + // All users can create, read, and delete their own push notification subscriptions. + { + Name: "NotificationPushSubscription", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, + Resource: rbac.ResourceNotificationPushSubscription.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, memberMe, orgMemberMe}, + false: {otherOrgMember, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin}, + }, + }, // AnyOrganization tests { Name: "CreateOrgMember", From 377eaabce98dc008cfc7dc39a8786bbf594a3ad7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 20:55:06 +0000 Subject: [PATCH 05/40] add cli command to regenerate vapid keypair and remove existing subscriptions --- cli/server.go | 18 ++- cli/server_regenerate_vapid_keypair.go | 111 +++++++++++++++++ cli/server_regenerate_vapid_keypair_test.go | 115 ++++++++++++++++++ cli/testdata/coder_server_--help.golden | 14 ++- ...ver_regenerate-vapid-keypair_--help.golden | 21 ++++ coderd/apidoc/docs.go | 78 ++++++++++++ coderd/apidoc/swagger.json | 70 +++++++++++ coderd/coderd.go | 6 +- coderd/coderdtest/coderdtest.go | 2 +- coderd/database/dbauthz/dbauthz.go | 44 ++++--- coderd/database/dbauthz/dbauthz_test.go | 4 + coderd/database/dbmem/dbmem.go | 10 +- coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 14 +++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 13 ++ coderd/database/queries/notifications.sql | 7 ++ coderd/notifications/push/push.go | 82 +++++++++++-- coderd/notifications/push/push_test.go | 89 ++++++++------ docs/manifest.json | 5 + docs/reference/api/notifications.md | 74 +++++++++++ docs/reference/cli/server.md | 13 +- .../cli/server_regenerate-vapid-keypair.md | 39 ++++++ .../cli/testdata/coder_server_--help.golden | 16 +-- ...ver_regenerate-vapid-keypair_--help.golden | 21 ++++ 25 files changed, 779 insertions(+), 99 deletions(-) create mode 100644 cli/server_regenerate_vapid_keypair.go create mode 100644 cli/server_regenerate_vapid_keypair_test.go create mode 100644 cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden create mode 100644 docs/reference/cli/server_regenerate-vapid-keypair.md create mode 100644 enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden diff --git a/cli/server.go b/cli/server.go index 75c9708935d41..0d2d796fd6146 100644 --- a/cli/server.go +++ b/cli/server.go @@ -776,11 +776,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } - pushNotifier, err := push.New(ctx, &options.Logger, options.Database) - if err != nil { - return xerrors.Errorf("failed to create push notifier: %w", err) + // Manage push notifications. + { + pushNotifier, err := push.New(ctx, &options.Logger, options.Database) + if err != nil { + options.Logger.Error(ctx, "failed to create push notifier", slog.Error(err)) + options.Logger.Warn(ctx, "push notifications will not work until the VAPID keys are regenerated") + pushNotifier = &push.NoopNotifier{ + Msg: "Push notifications are disabled due to a system error. Please contact your Coder administrator.", + } + } + options.PushNotifier = pushNotifier } - options.PushNotifier = pushNotifier githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) if err != nil { @@ -1262,6 +1269,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } createAdminUserCmd := r.newCreateAdminUserCommand() + regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand() rawURLOpt := serpent.Option{ Flag: "raw-url", @@ -1275,7 +1283,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. serverCmd.Children = append( serverCmd.Children, - createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, + createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd, ) return serverCmd diff --git a/cli/server_regenerate_vapid_keypair.go b/cli/server_regenerate_vapid_keypair.go new file mode 100644 index 0000000000000..b7ef08502ffd4 --- /dev/null +++ b/cli/server_regenerate_vapid_keypair.go @@ -0,0 +1,111 @@ +//go:build !slim + +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/awsiamrds" + "github.com/coder/coder/v2/coderd/notifications/push" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command { + var ( + regenVapidKeypairDBURL string + regenVapidKeypairPgAuth string + ) + regenerateVapidKeypairCommand := &serpent.Command{ + Use: "regenerate-vapid-keypair", + Short: "Regenerate the VAPID keypair used for push notifications.", + Handler: func(inv *serpent.Invocation) error { + var ( + ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...) + cfg = r.createConfig() + logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)) + ) + if r.verbose { + logger = logger.Leveled(slog.LevelDebug) + } + + defer cancel() + + if regenVapidKeypairDBURL == "" { + cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath()) + url, closePg, err := startBuiltinPostgres(ctx, cfg, logger, "") + if err != nil { + return err + } + defer func() { + _ = closePg() + }() + regenVapidKeypairDBURL = url + } + + sqlDriver := "postgres" + var err error + if codersdk.PostgresAuth(regenVapidKeypairPgAuth) == codersdk.PostgresAuthAWSIAMRDS { + sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver) + if err != nil { + return xerrors.Errorf("register aws rds iam auth: %w", err) + } + } + + sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, regenVapidKeypairDBURL, nil) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + db := database.New(sqlDB) + + // Confirm that the user really wants to regenerate the VAPID keypair. + cliui.Infof(inv.Stdout, "Regenerating VAPID keypair...") + cliui.Infof(inv.Stdout, "This will delete all existing push notification subscriptions.") + cliui.Infof(inv.Stdout, "Are you sure you want to continue? (y/N)") + + if resp, err := cliui.Prompt(inv, cliui.PromptOptions{ + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil || resp != cliui.ConfirmYes { + return xerrors.Errorf("VAPID keypair regeneration failed: %w", err) + } + + if _, _, err := push.RegenerateVAPIDKeys(ctx, db); err != nil { + return xerrors.Errorf("regenerate vapid keypair: %w", err) + } + + _, _ = fmt.Fprintln(inv.Stdout, "VAPID keypair regenerated successfully.") + return nil + }, + } + + regenerateVapidKeypairCommand.Options.Add( + cliui.SkipPromptOption(), + serpent.Option{ + Env: "CODER_PG_CONNECTION_URL", + Flag: "postgres-url", + Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).", + Value: serpent.StringOf(®enVapidKeypairDBURL), + }, + serpent.Option{ + Name: "Postgres Connection Auth", + Description: "Type of auth to use when connecting to postgres.", + Flag: "postgres-connection-auth", + Env: "CODER_PG_CONNECTION_AUTH", + Default: "password", + Value: serpent.EnumOf(®enVapidKeypairPgAuth, codersdk.PostgresAuthDrivers...), + }, + ) + + return regenerateVapidKeypairCommand +} diff --git a/cli/server_regenerate_vapid_keypair_test.go b/cli/server_regenerate_vapid_keypair_test.go new file mode 100644 index 0000000000000..e3069c9e3db6c --- /dev/null +++ b/cli/server_regenerate_vapid_keypair_test.go @@ -0,0 +1,115 @@ +package cli_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestRegenerateVapidKeypair(t *testing.T) { + t.Parallel() + + t.Run("NoExistingVAPIDKeys", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + defer sqlDB.Close() + + db := database.New(sqlDB) + // Ensure there is no existing VAPID keypair. + rows, err := db.GetNotificationVAPIDKeys(ctx) + require.NoError(t, err) + require.Empty(t, rows) + + inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes") + + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + clitest.Start(t, inv) + + pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...") + pty.ExpectMatchContext(ctx, "This will delete all existing push notification subscriptions.") + pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)") + pty.WriteLine("y") + pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.") + + // Ensure the VAPID keypair was created. + keys, err := db.GetNotificationVAPIDKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys.VapidPublicKey) + require.NotEmpty(t, keys.VapidPrivateKey) + }) + + t.Run("ExistingVAPIDKeys", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + defer sqlDB.Close() + + db := database.New(sqlDB) + for i := 0; i < 10; i++ { + // Insert a few fake users. + u := dbgen.User(t, db, database.User{}) + // Insert a few fake push subscriptions for each user. + for j := 0; j < 10; j++ { + _ = dbgen.NotificationPushSubscription(t, db, database.InsertNotificationPushSubscriptionParams{ + UserID: u.ID, + }) + } + } + + inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes") + + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + clitest.Start(t, inv) + + pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...") + pty.ExpectMatchContext(ctx, "This will delete all existing push notification subscriptions.") + pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)") + pty.WriteLine("y") + pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.") + + // Ensure the VAPID keypair was created. + keys, err := db.GetNotificationVAPIDKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys.VapidPublicKey) + require.NotEmpty(t, keys.VapidPrivateKey) + + // Ensure the push subscriptions were deleted. + var count int64 + rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM notification_push_subscriptions") + require.NoError(t, err) + t.Cleanup(func() { + _ = rows.Close() + }) + require.True(t, rows.Next()) + require.NoError(t, rows.Scan(&count)) + require.Equal(t, int64(0), count) + }) +} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 174b25eae1331..09142b9d0b441 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -6,12 +6,14 @@ USAGE: Start a Coder server SUBCOMMANDS: - create-admin-user Create a new admin user with the given username, - email and password and adds it to every - organization. - postgres-builtin-serve Run the built-in PostgreSQL deployment. - postgres-builtin-url Output the connection URL for the built-in - PostgreSQL deployment. + create-admin-user Create a new admin user with the given username, + email and password and adds it to every + organization. + postgres-builtin-serve Run the built-in PostgreSQL deployment. + postgres-builtin-url Output the connection URL for the built-in + PostgreSQL deployment. + regenerate-vapid-keypair Regenerate the VAPID keypair used for push + notifications. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false) diff --git a/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden b/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden new file mode 100644 index 0000000000000..55d01cbcfc560 --- /dev/null +++ b/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder server regenerate-vapid-keypair [flags] + + Regenerate the VAPID keypair used for push notifications. + +OPTIONS: + --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) + Type of auth to use when connecting to postgres. + + --postgres-url string, $CODER_PG_CONNECTION_URL + URL of a PostgreSQL database. If empty, the built-in PostgreSQL + deployment will be used (Coder must not be already running in this + case). + + -y, --yes bool + Bypass prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f4a51e42bd959..857a28187087c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7223,6 +7223,84 @@ const docTemplate = `{ } } }, + "/users/{user}/notifications/push/subscription": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Create user push notification subscription", + "operationId": "create-user-push-notification-subscription", + "parameters": [ + { + "description": "Push notification subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PushNotificationSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Delete user push notification subscription", + "operationId": "delete-user-push-notification-subscription", + "parameters": [ + { + "description": "Push notification subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeletePushNotificationSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 80313c4dc7d6a..da20509a4ddab 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6384,6 +6384,76 @@ } } }, + "/users/{user}/notifications/push/subscription": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Notifications"], + "summary": "Create user push notification subscription", + "operationId": "create-user-push-notification-subscription", + "parameters": [ + { + "description": "Push notification subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PushNotificationSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Notifications"], + "summary": "Delete user push notification subscription", + "operationId": "delete-user-push-notification-subscription", + "parameters": [ + { + "description": "Push notification subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeletePushNotificationSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 5f8de6def2379..fac8dfc696dda 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -263,7 +263,7 @@ type Options struct { Clock quartz.Clock // PushNotifier is a way to send push notifications to users. - PushNotifier *push.Notifier + PushNotifier push.NotificationDispatcher } // @title Coder API @@ -585,7 +585,7 @@ func New(options *Options) *API { WorkspaceProxy: false, UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), DeploymentID: api.DeploymentID, - PushNotificationsPublicKey: api.PushNotifier.VAPIDPublicKey, + PushNotificationsPublicKey: api.PushNotifier.PublicKey(), Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ @@ -1505,7 +1505,7 @@ type API struct { NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher TailnetClientService *tailnet.ClientService // PushNotifier is a way to send push notifications to users. - PushNotifier *push.Notifier + PushNotifier push.NotificationDispatcher QuotaCommitter atomic.Pointer[proto.QuotaCommitter] AppearanceFetcher atomic.Pointer[appearance.Fetcher] // WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 24a9b6c257e96..8821f449fab68 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -162,7 +162,7 @@ type Options struct { Logger *slog.Logger StatsBatcher workspacestats.Batcher - PushNotifier *push.Notifier + PushNotifier push.NotificationDispatcher WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions AllowWorkspaceRenames bool NewTicker func(duration time.Duration) (<-chan time.Time, func()) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1511a45270a56..c3c2469508424 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -317,24 +317,25 @@ var ( Identifier: rbac.RoleIdentifier{Name: "system"}, DisplayName: "Coder", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWildcard.Type: {policy.ActionRead}, - rbac.ResourceApiKey.Type: rbac.ResourceApiKey.AvailableActions(), - rbac.ResourceGroup.Type: {policy.ActionCreate, policy.ActionUpdate}, - rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(), - rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(), - rbac.ResourceSystem.Type: {policy.WildcardSymbol}, - rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, - rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, - rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, - rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), - rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, - rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, - rbac.ResourceWorkspaceProxy.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationPreference.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceCryptoKey.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceWildcard.Type: {policy.ActionRead}, + rbac.ResourceApiKey.Type: rbac.ResourceApiKey.AvailableActions(), + rbac.ResourceGroup.Type: {policy.ActionCreate, policy.ActionUpdate}, + rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(), + rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(), + rbac.ResourceSystem.Type: {policy.WildcardSymbol}, + rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, + rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, + rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), + rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, + rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, + rbac.ResourceWorkspaceProxy.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationPreference.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationPushSubscription.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceCryptoKey.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -1162,6 +1163,13 @@ func (q *querier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) e return q.db.DeleteAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceNotificationPushSubscription); err != nil { + return err + } + return q.db.DeleteAllNotificationPushSubscriptions(ctx) +} + func (q *querier) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 021fba56799cb..bdede0ef7848d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4605,6 +4605,10 @@ func (s *MethodTestSuite) TestNotifications() { Endpoint: push.Endpoint, }).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) })) + s.Run("DeleteAllNotificationPushSubscriptions", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceNotificationPushSubscription, policy.ActionDelete) + })) // Notification templates s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 74576bcb7188f..190dcb6b6a2aa 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1838,6 +1838,14 @@ func (q *FakeQuerier) DeleteAPIKeysByUserID(_ context.Context, userID uuid.UUID) return nil } +func (q *FakeQuerier) DeleteAllNotificationPushSubscriptions(_ context.Context) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.notificationPushSubscriptions = make([]database.NotificationPushSubscription, 0) + return nil +} + func (*FakeQuerier) DeleteAllTailnetClientSubscriptions(_ context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -8517,7 +8525,7 @@ func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.Insert return newGroups, nil } -func (q *FakeQuerier) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { +func (q *FakeQuerier) InsertNotificationPushSubscription(_ context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { err := validateDatabaseType(arg) if err != nil { return database.NotificationPushSubscription{}, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 8ea61686f4fd2..84a7f97b02cc8 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -207,6 +207,13 @@ func (m queryMetricsStore) DeleteAPIKeysByUserID(ctx context.Context, userID uui return err } +func (m queryMetricsStore) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { + start := time.Now() + r0 := m.s.DeleteAllNotificationPushSubscriptions(ctx) + m.queryLatencies.WithLabelValues("DeleteAllNotificationPushSubscriptions").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { start := time.Now() r0 := m.s.DeleteAllTailnetClientSubscriptions(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5ac331b66b50b..2528effd521c2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -290,6 +290,20 @@ func (mr *MockStoreMockRecorder) DeleteAPIKeysByUserID(ctx, userID any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteAPIKeysByUserID), ctx, userID) } +// DeleteAllNotificationPushSubscriptions mocks base method. +func (m *MockStore) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllNotificationPushSubscriptions", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllNotificationPushSubscriptions indicates an expected call of DeleteAllNotificationPushSubscriptions. +func (mr *MockStoreMockRecorder) DeleteAllNotificationPushSubscriptions(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllNotificationPushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteAllNotificationPushSubscriptions), ctx) +} + // DeleteAllTailnetClientSubscriptions mocks base method. func (m *MockStore) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index fe333d9e9b79a..5f85a0cb45927 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -67,6 +67,11 @@ type sqlcQuerier interface { CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + // Deletes all existing notification push subscriptions. + // This should be called when the VAPID keypair is regenerated, as the old + // keypair will no longer be valid and all existing subscriptions will need to + // be recreated. + DeleteAllNotificationPushSubscriptions(ctx context.Context) error DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 124311a9261d4..dc7d13999fb88 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3988,6 +3988,19 @@ func (q *sqlQuerier) BulkMarkNotificationMessagesSent(ctx context.Context, arg B return result.RowsAffected() } +const deleteAllNotificationPushSubscriptions = `-- name: DeleteAllNotificationPushSubscriptions :exec +TRUNCATE TABLE notification_push_subscriptions +` + +// Deletes all existing notification push subscriptions. +// This should be called when the VAPID keypair is regenerated, as the old +// keypair will no longer be valid and all existing subscriptions will need to +// be recreated. +func (q *sqlQuerier) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllNotificationPushSubscriptions) + return err +} + const deleteNotificationPushSubscriptionByEndpoint = `-- name: DeleteNotificationPushSubscriptionByEndpoint :exec DELETE FROM notification_push_subscriptions WHERE user_id = $1 AND endpoint = $2 diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index 160f38d08f06f..7dbf9778d5fa2 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -207,3 +207,10 @@ WHERE id = ANY(@ids::uuid[]); -- name: DeleteNotificationPushSubscriptionByEndpoint :exec DELETE FROM notification_push_subscriptions WHERE user_id = @user_id AND endpoint = @endpoint; + +-- name: DeleteAllNotificationPushSubscriptions :exec +-- Deletes all existing notification push subscriptions. +-- This should be called when the VAPID keypair is regenerated, as the old +-- keypair will no longer be valid and all existing subscriptions will need to +-- be recreated. +TRUNCATE TABLE notification_push_subscriptions; diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index 73393023ff816..211d63bf4fd56 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -20,6 +20,14 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// NotificationDispatcher is an interface that can be used to dispatch +// push notifications. +type NotificationDispatcher interface { + Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.PushNotification) error + PublicKey() string + PrivateKey() string +} + // New creates a new push manager to dispatch push notifications. // // This is *not* integrated into the enqueue system unfortunately. @@ -28,7 +36,7 @@ import ( // 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) (*Notifier, error) { +func New(ctx context.Context, log *slog.Logger, db database.Store) (NotificationDispatcher, error) { keys, err := db.GetNotificationVAPIDKeys(ctx) if err != nil { if !errors.Is(err, sql.ErrNoRows) { @@ -36,19 +44,16 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (*Notifier, e } } if keys.VapidPublicKey == "" || keys.VapidPrivateKey == "" { - privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + // Generate new VAPID keys. This also deletes all existing push + // subscriptions as part of the transaction, as they are no longer + // valid. + newPrivateKey, newPublicKey, err := RegenerateVAPIDKeys(ctx, db) if err != nil { - return nil, xerrors.Errorf("generate vapid keys: %w", err) + return nil, xerrors.Errorf("regenerate vapid keys: %w", err) } - err = db.UpsertNotificationVAPIDKeys(ctx, database.UpsertNotificationVAPIDKeysParams{ - VapidPublicKey: publicKey, - VapidPrivateKey: privateKey, - }) - if err != nil { - return nil, xerrors.Errorf("upsert notification vapid keys: %w", err) - } - keys.VapidPublicKey = publicKey - keys.VapidPrivateKey = privateKey + + keys.VapidPublicKey = newPublicKey + keys.VapidPrivateKey = newPrivateKey } return &Notifier{ @@ -138,3 +143,56 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification return nil } + +func (n *Notifier) PublicKey() string { + return n.VAPIDPublicKey +} + +func (n *Notifier) PrivateKey() string { + return n.VAPIDPrivateKey +} + +// NoopNotifier is a Notifier that does nothing except return an error. +// This is returned when push notifications are disabled, or if there was an +// error generating the VAPID keys. +type NoopNotifier struct { + Msg string +} + +func (n *NoopNotifier) Dispatch(context.Context, uuid.UUID, codersdk.PushNotification) error { + return xerrors.New(n.Msg) +} + +func (*NoopNotifier) PublicKey() string { + return "" +} + +func (*NoopNotifier) PrivateKey() string { + return "" +} + +// RegenerateVAPIDKeys regenerates the VAPID keys and deletes all existing +// push subscriptions as part of the transaction, as they are no longer valid. +func RegenerateVAPIDKeys(ctx context.Context, db database.Store) (newPrivateKey string, newPublicKey string, err error) { + newPrivateKey, newPublicKey, err = webpush.GenerateVAPIDKeys() + if err != nil { + return "", "", xerrors.Errorf("generate new vapid keypair: %w", err) + } + + if txErr := db.InTx(func(tx database.Store) error { + if err := tx.DeleteAllNotificationPushSubscriptions(ctx); err != nil { + return xerrors.Errorf("delete all notification push subscriptions: %w", err) + } + if err := tx.UpsertNotificationVAPIDKeys(ctx, database.UpsertNotificationVAPIDKeysParams{ + VapidPrivateKey: newPrivateKey, + VapidPublicKey: newPublicKey, + }); err != nil { + return xerrors.Errorf("upsert notification vapid key: %w", err) + } + return nil + }, nil); txErr != nil { + return "", "", xerrors.Errorf("regenerate vapid keypair: %w", txErr) + } + + return newPrivateKey, newPublicKey, nil +} diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go index 4c1e947c167d9..3549ee83f9d34 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/notifications/push/push_test.go @@ -13,10 +13,12 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) const ( @@ -29,13 +31,14 @@ func TestPush(t *testing.T) { t.Run("SuccessfulDelivery", func(t *testing.T) { t.Parallel() - manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - userID := uuid.New() - sub, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + user := dbgen.User(t, store, database.User{}) + sub, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: uuid.New(), - UserID: userID, + UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, @@ -52,10 +55,10 @@ func TestPush(t *testing.T) { Icon: "workspace", } - err = manager.Dispatch(context.Background(), userID, notification) + err = manager.Dispatch(ctx, user.ID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 1, "One subscription should be returned") assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") @@ -63,14 +66,15 @@ func TestPush(t *testing.T) { t.Run("ExpiredSubscription", func(t *testing.T) { t.Parallel() - manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusGone) }) - userID := uuid.New() + user := dbgen.User(t, store, database.User{}) subID := uuid.New() - _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: subID, - UserID: userID, + UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, @@ -83,24 +87,26 @@ func TestPush(t *testing.T) { Body: "Test Body", } - err = manager.Dispatch(context.Background(), userID, notification) + err = manager.Dispatch(ctx, user.ID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 0, "No subscriptions should be returned") }) t.Run("FailedDelivery", func(t *testing.T) { t.Parallel() - manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("Invalid request")) }) - userID := uuid.New() - sub, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + + user := dbgen.User(t, store, database.User{}) + sub, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: uuid.New(), - UserID: userID, + UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, @@ -113,11 +119,11 @@ func TestPush(t *testing.T) { Body: "Test Body", } - err = manager.Dispatch(context.Background(), userID, notification) + err = manager.Dispatch(ctx, user.ID, notification) require.Error(t, err) assert.Contains(t, err.Error(), "Invalid request") - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 1, "One subscription should be returned") assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") @@ -125,10 +131,10 @@ func TestPush(t *testing.T) { t.Run("MultipleSubscriptions", func(t *testing.T) { t.Parallel() - + ctx := testutil.Context(t, testutil.WaitShort) var okEndpointCalled bool var goneEndpointCalled bool - manager, store, serverOKURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + manager, store, serverOKURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { okEndpointCalled = true w.WriteHeader(http.StatusOK) }) @@ -141,13 +147,13 @@ func TestPush(t *testing.T) { serverGoneURL := serverGone.URL // Setup subscriptions pointing to our test servers - userID := uuid.New() + user := dbgen.User(t, store, database.User{}) sub1ID := uuid.New() sub2ID := uuid.New() - _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: sub1ID, - UserID: userID, + UserID: user.ID, Endpoint: serverOKURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, @@ -155,9 +161,9 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - _, err = store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + _, err = store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: sub2ID, - UserID: userID, + UserID: user.ID, Endpoint: serverGoneURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, @@ -173,7 +179,7 @@ func TestPush(t *testing.T) { }, } - err = manager.Dispatch(context.Background(), userID, notification) + err = manager.Dispatch(context.Background(), user.ID, notification) require.NoError(t, err) assert.True(t, okEndpointCalled, "The valid endpoint should be called") assert.True(t, goneEndpointCalled, "The expired endpoint should be called") @@ -185,23 +191,25 @@ func TestPush(t *testing.T) { t.Run("NotificationPayload", func(t *testing.T) { t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) var requestReceived bool - manager, store, serverURL := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { requestReceived = true w.WriteHeader(http.StatusOK) }) - userID := uuid.New() + user := dbgen.User(t, store, database.User{}) - _, err := store.InsertNotificationPushSubscription(context.Background(), database.InsertNotificationPushSubscriptionParams{ + _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ ID: uuid.New(), CreatedAt: dbtime.Now(), - UserID: userID, + UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, EndpointP256dhKey: validEndpointP256dhKey, }) - require.NoError(t, err) + require.NoError(t, err, "Failed to insert push subscription") notification := codersdk.PushNotification{ Title: "Test Notification", @@ -213,14 +221,15 @@ func TestPush(t *testing.T) { Icon: "workspace-icon", } - err = manager.Dispatch(context.Background(), userID, notification) - require.NoError(t, err) - assert.True(t, requestReceived, "The push notification request should have been received by the server") + err = manager.Dispatch(ctx, user.ID, notification) + 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") }) t.Run("NoSubscriptions", func(t *testing.T) { t.Parallel() - manager, store, _ := setupPushTest(t, func(w http.ResponseWriter, _ *http.Request) { + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, _ := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -230,25 +239,25 @@ func TestPush(t *testing.T) { Body: "Test Body", } - err := manager.Dispatch(context.Background(), userID, notification) + err := manager.Dispatch(ctx, userID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(context.Background(), userID) + subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, userID) require.NoError(t, err) assert.Empty(t, subscriptions, "No subscriptions should be returned") }) } // setupPushTest creates a common test setup for push notification tests -func setupPushTest(t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (*push.Notifier, database.Store, string) { +func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (push.NotificationDispatcher, database.Store, string) { 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 := push.New(context.Background(), &logger, db) - require.NoError(t, err) + manager, err := push.New(ctx, &logger, db) + require.NoError(t, err, "Failed to create push manager") return manager, db, server.URL } diff --git a/docs/manifest.json b/docs/manifest.json index 7b15d7ac81754..c87610f69886d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1320,6 +1320,11 @@ "description": "Output the connection URL for the built-in PostgreSQL deployment.", "path": "reference/cli/server_postgres-builtin-url.md" }, + { + "title": "server regenerate-vapid-keypair", + "description": "Regenerate the VAPID keypair used for push notifications.", + "path": "reference/cli/server_regenerate-vapid-keypair.md" + }, { "title": "show", "description": "Display details of a workspace's resources and agents", diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 188f326dc2509..d7d6d4c603f12 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -510,3 +510,77 @@ Status Code **200** | `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create user push notification subscription + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/subscription \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /users/{user}/notifications/push/subscription` + +> Body parameter + +```json +{ + "auth_key": "string", + "endpoint": "string", + "p256dh_key": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------------|----------|--------------------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.PushNotificationSubscription](schemas.md#codersdkpushnotificationsubscription) | true | Push notification subscription | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete user push notification subscription + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/notifications/push/subscription \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /users/{user}/notifications/push/subscription` + +> Body parameter + +```json +{ + "endpoint": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------------------------|----------|--------------------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.DeletePushNotificationSubscription](schemas.md#codersdkdeletepushnotificationsubscription) | true | Push notification subscription | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 888e569f9d5bc..acaf22e8d7db9 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -11,12 +11,13 @@ coder server [flags] ## Subcommands -| Name | Purpose | -|---------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| -| [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | -| [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | -| [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | -| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. | +| Name | Purpose | +|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | +| [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | +| [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | +| [regenerate-vapid-keypair](./server_regenerate-vapid-keypair.md) | Regenerate the VAPID keypair used for push notifications. | +| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. | ## Options diff --git a/docs/reference/cli/server_regenerate-vapid-keypair.md b/docs/reference/cli/server_regenerate-vapid-keypair.md new file mode 100644 index 0000000000000..3446a60e133d1 --- /dev/null +++ b/docs/reference/cli/server_regenerate-vapid-keypair.md @@ -0,0 +1,39 @@ + +# server regenerate-vapid-keypair + +Regenerate the VAPID keypair used for push notifications. + +## Usage + +```console +coder server regenerate-vapid-keypair [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass prompts. + +### --postgres-url + +| | | +|-------------|---------------------------------------| +| Type | string | +| Environment | $CODER_PG_CONNECTION_URL | + +URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). + +### --postgres-connection-auth + +| | | +|-------------|----------------------------------------| +| Type | password\|awsiamrds | +| Environment | $CODER_PG_CONNECTION_AUTH | +| Default | password | + +Type of auth to use when connecting to postgres. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e8f71dcd781dc..68871b89dddd3 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -6,13 +6,15 @@ USAGE: Start a Coder server SUBCOMMANDS: - create-admin-user Create a new admin user with the given username, - email and password and adds it to every - organization. - dbcrypt Manage database encryption. - postgres-builtin-serve Run the built-in PostgreSQL deployment. - postgres-builtin-url Output the connection URL for the built-in - PostgreSQL deployment. + create-admin-user Create a new admin user with the given username, + email and password and adds it to every + organization. + dbcrypt Manage database encryption. + postgres-builtin-serve Run the built-in PostgreSQL deployment. + postgres-builtin-url Output the connection URL for the built-in + PostgreSQL deployment. + regenerate-vapid-keypair Regenerate the VAPID keypair used for push + notifications. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false) diff --git a/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden b/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden new file mode 100644 index 0000000000000..55d01cbcfc560 --- /dev/null +++ b/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder server regenerate-vapid-keypair [flags] + + Regenerate the VAPID keypair used for push notifications. + +OPTIONS: + --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) + Type of auth to use when connecting to postgres. + + --postgres-url string, $CODER_PG_CONNECTION_URL + URL of a PostgreSQL database. If empty, the built-in PostgreSQL + deployment will be used (Coder must not be already running in this + case). + + -y, --yes bool + Bypass prompts. + +——— +Run `coder --help` for a list of global options. From 982acef41ac359e138f6c1feff33d3b110a0cb0d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:01:38 +0000 Subject: [PATCH 06/40] remove dbauthz system usage --- coderd/coderdtest/coderdtest.go | 2 +- coderd/database/dbauthz/dbauthz.go | 43 +++++++++++++++--------------- coderd/notifications/push/push.go | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 8821f449fab68..aa6c23e14f751 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -284,7 +284,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.PushNotifier == nil { // nolint:gocritic // Gets/sets VAPID keys. - pushNotifier, err := push.New(dbauthz.AsSystemRestricted(context.Background()), options.Logger, options.Database) + pushNotifier, err := push.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database) if err != nil { panic(xerrors.Errorf("failed to create push notifier: %w", err)) } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c3c2469508424..6b3b0208226fb 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -281,8 +281,10 @@ var ( Identifier: rbac.RoleIdentifier{Name: "notifier"}, DisplayName: "Notifier", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, + rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, + rbac.ResourceNotificationPushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -317,25 +319,24 @@ var ( Identifier: rbac.RoleIdentifier{Name: "system"}, DisplayName: "Coder", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceWildcard.Type: {policy.ActionRead}, - rbac.ResourceApiKey.Type: rbac.ResourceApiKey.AvailableActions(), - rbac.ResourceGroup.Type: {policy.ActionCreate, policy.ActionUpdate}, - rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(), - rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(), - rbac.ResourceSystem.Type: {policy.WildcardSymbol}, - rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, - rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, - rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, - rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), - rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, - rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, - rbac.ResourceWorkspaceProxy.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationPreference.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceNotificationPushSubscription.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceCryptoKey.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceWildcard.Type: {policy.ActionRead}, + rbac.ResourceApiKey.Type: rbac.ResourceApiKey.AvailableActions(), + rbac.ResourceGroup.Type: {policy.ActionCreate, policy.ActionUpdate}, + rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(), + rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(), + rbac.ResourceSystem.Type: {policy.WildcardSymbol}, + rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, + rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, + rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), + rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, + rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, + rbac.ResourceWorkspaceProxy.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationPreference.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceNotificationTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceCryptoKey.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index 211d63bf4fd56..f43e107edf504 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -135,7 +135,7 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification if len(cleanupSubscriptions) > 0 { // nolint:gocritic // These are known to be invalid subscriptions. - err = n.store.DeleteNotificationPushSubscriptions(dbauthz.AsSystemRestricted(ctx), cleanupSubscriptions) + err = n.store.DeleteNotificationPushSubscriptions(dbauthz.AsNotifier(ctx), cleanupSubscriptions) if err != nil { n.log.Error(ctx, "failed to delete stale push subscriptions", slog.Error(err)) } From d82073e053b18f2c8582859174ea07d3525983c5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:25:20 +0000 Subject: [PATCH 07/40] add test endpoint for push notifications --- coderd/apidoc/docs.go | 28 ++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 26 ++++++++++++++++++++++++++ coderd/coderd.go | 1 + docs/reference/api/notifications.md | 26 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 857a28187087c..3d0ae4fd2fc4e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7301,6 +7301,34 @@ const docTemplate = `{ } } }, + "/users/{user}/notifications/push/test": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Notifications" + ], + "summary": "Send a test push notification", + "operationId": "send-a-test-push-notification", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index da20509a4ddab..35222db3506f1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6454,6 +6454,32 @@ } } }, + "/users/{user}/notifications/push/test": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Notifications"], + "summary": "Send a test push notification", + "operationId": "send-a-test-push-notification", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index fac8dfc696dda..160a462af116f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1203,6 +1203,7 @@ func New(options *Options) *API { r.Route("/push", func(r chi.Router) { r.Post("/subscription", api.postUserPushNotificationSubscription) r.Delete("/subscription", api.deleteUserPushNotificationSubscription) + r.Post("/test", api.postUserPushNotificationTest) }) }) }) diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index d7d6d4c603f12..c30dbc08c27b6 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -584,3 +584,29 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/notifications/push/s | 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Send a test push notification + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/test \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /users/{user}/notifications/push/test` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). From fc59c70099e85ce484f27768e0a384075b484b19 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:25:33 +0000 Subject: [PATCH 08/40] make linter happy --- site/src/contexts/usePushNotifications.ts | 5 ----- site/src/modules/dashboard/Navbar/NavbarView.tsx | 2 -- site/src/serviceWorker.ts | 14 -------------- 3 files changed, 21 deletions(-) diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/usePushNotifications.ts index cd7501be133c7..2915bc8ced494 100644 --- a/site/src/contexts/usePushNotifications.ts +++ b/site/src/contexts/usePushNotifications.ts @@ -48,11 +48,6 @@ export const usePushNotifications = (): PushNotifications => { setLoading(true); const registration = await navigator.serviceWorker.ready; - console.log( - "BUILD INFO", - buildInfoQuery.data?.push_notifications_public_key, - ); - // Note: You'd typically get this key from your server const vapidPublicKey = buildInfoQuery.data?.push_notifications_public_key; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index a19a8ecb3dc06..64e3a1e0bba3e 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -47,8 +47,6 @@ export const NavbarView: FC = ({ const { subscribed, loading, subscribe, unsubscribe } = usePushNotifications(); - console.log("HERE"); - return (
diff --git a/site/src/serviceWorker.ts b/site/src/serviceWorker.ts index a9d050d77c23c..88aa4d82e16b0 100644 --- a/site/src/serviceWorker.ts +++ b/site/src/serviceWorker.ts @@ -28,16 +28,10 @@ self.addEventListener("push", (event) => { return; } - console.log("PAYLOAD", payload); - event.waitUntil( self.registration.showNotification(payload.title, { body: payload.body || "", icon: payload.icon || "/favicon.ico", - // actions: payload.actions.map((action: PushNotificationAction) => ({ - // title: action.title, - // action: action.url, - // })) || [], }), ); }); @@ -45,12 +39,4 @@ self.addEventListener("push", (event) => { // Handle notification click self.addEventListener("notificationclick", (event) => { event.notification.close(); - - // If a link is provided, navigate to it - const data = event.notification.data; - // if (data && data.url) { - // event.waitUntil( - // clients.openWindow(data.url) - // ); - // } }); From cbff3cac0cac381e56d11cd15dfeeb01efc7eec4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:40:47 +0000 Subject: [PATCH 09/40] test push notifications endpoint --- coderd/notifications_test.go | 4 ++++ codersdk/deployment.go | 22 ++++++++++++++++++++++ codersdk/notifications.go | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 4dab1be470df7..a8a09a1441010 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -412,6 +412,10 @@ func TestPushNotificationSubscription(t *testing.T) { require.NoError(t, err, "create notification push subscription") require.True(t, <-handlerCalled, "handler should have been called") + err = memberClient.TestPushNotification(ctx) + require.NoError(t, err, "test push notification") + require.True(t, <-handlerCalled, "handler should have been called again") + err = memberClient.DeleteNotificationPushSubscription(ctx, "me", codersdk.DeletePushNotificationSubscription{ Endpoint: server.URL, }) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f5f60dbbdb674..1c016512164d7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -700,6 +700,12 @@ type NotificationsConfig struct { Webhook NotificationsWebhookConfig `json:"webhook" typescript:",notnull"` // Inbox settings. Inbox NotificationsInboxConfig `json:"inbox" typescript:",notnull"` + // Push notification settings. + Push NotificationsPushConfig `json:"push" typescript:",notnull"` +} + +type NotificationsPushConfig struct { + Enabled serpent.Bool `json:"enabled" typescript:",notnull"` } // Are either of the notification methods enabled? @@ -1001,6 +1007,11 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Parent: &deploymentGroupNotifications, YAML: "inbox", } + deploymentGroupNotificationsPush = serpent.Group{ + Name: "Push", + Parent: &deploymentGroupNotifications, + YAML: "push", + } ) httpAddress := serpent.Option{ @@ -2968,6 +2979,17 @@ Write out the current server config as YAML to stdout.`, Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), Hidden: true, // Hidden because most operators should not need to modify this. }, + // Push notifications. + { + Name: "Notifications: Push: Enabled", + Description: "Enable push notifications using VAPID.", + Flag: "notifications-push-enabled", + Env: "CODER_NOTIFICATIONS_PUSH_ENABLED", + Value: &c.Notifications.Push.Enabled, + Default: "false", + Group: &deploymentGroupNotificationsPush, + YAML: "enabled", + }, } return opts diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 904105e9c98f3..5dc4617060c8b 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -265,3 +265,19 @@ func (c *Client) DeleteNotificationPushSubscription(ctx context.Context, user st } return nil } + +func (c *Client) TestPushNotification(ctx context.Context) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/test", Me), PushNotification{ + Title: "It's working!", + Body: "You've subscribed to push notifications.", + }) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} From 9e7e1dc09e3ffec1f99e033654829483b5c7e968 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:41:10 +0000 Subject: [PATCH 10/40] add deployment config for push notifications --- cli/server.go | 6 ++++- cli/testdata/coder_server_--help.golden | 4 ++++ cli/testdata/server-config.yaml.golden | 4 ++++ coderd/apidoc/docs.go | 16 +++++++++++++ coderd/apidoc/swagger.json | 16 +++++++++++++ docs/reference/api/general.md | 3 +++ docs/reference/api/schemas.md | 24 +++++++++++++++++++ docs/reference/cli/server.md | 11 +++++++++ .../cli/testdata/coder_server_--help.golden | 4 ++++ site/src/api/typesGenerated.ts | 6 +++++ 10 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cli/server.go b/cli/server.go index 0d2d796fd6146..1fa054207e702 100644 --- a/cli/server.go +++ b/cli/server.go @@ -777,7 +777,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } // Manage push notifications. - { + if options.DeploymentValues.Notifications.Push.Enabled { pushNotifier, err := push.New(ctx, &options.Logger, options.Database) if err != nil { options.Logger.Error(ctx, "failed to create push notifier", slog.Error(err)) @@ -787,6 +787,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } options.PushNotifier = pushNotifier + } else { + options.PushNotifier = &push.NoopNotifier{ + Msg: "Push notifications are not configured.", + } } githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 09142b9d0b441..89a995774973d 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -479,6 +479,10 @@ NOTIFICATIONS / INBOX OPTIONS: --notifications-inbox-enabled bool, $CODER_NOTIFICATIONS_INBOX_ENABLED (default: true) Enable Coder Inbox. +NOTIFICATIONS / PUSH OPTIONS: + --notifications-push-enabled bool, $CODER_NOTIFICATIONS_PUSH_ENABLED (default: false) + Enable push notifications using VAPID. + NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 39ed5eb2c047d..9fe93b3caf2e6 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -681,3 +681,7 @@ notifications: # How often to query the database for queued notifications. # (default: 15s, type: duration) fetchInterval: 15s + push: + # Enable push notifications using VAPID. + # (default: false, type: bool) + enabled: false diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3d0ae4fd2fc4e..6d9429ba08500 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12800,6 +12800,14 @@ const docTemplate = `{ "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, + "push": { + "description": "Push notification settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsPushConfig" + } + ] + }, "retry_interval": { "description": "The minimum time between retries.", "type": "integer" @@ -12917,6 +12925,14 @@ const docTemplate = `{ } } }, + "codersdk.NotificationsPushConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "codersdk.NotificationsSettings": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 35222db3506f1..285a0eadc5ce3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11501,6 +11501,14 @@ "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, + "push": { + "description": "Push notification settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsPushConfig" + } + ] + }, "retry_interval": { "description": "The minimum time between retries.", "type": "integer" @@ -11618,6 +11626,14 @@ } } }, + "codersdk.NotificationsPushConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "codersdk.NotificationsSettings": { "type": "object", "properties": { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 2339fc17d0800..1fce46fec6c77 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -301,6 +301,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "lease_period": 0, "max_send_attempts": 0, "method": "string", + "push": { + "enabled": true + }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ab4203746d137..ca1b3edae1d06 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1966,6 +1966,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "lease_period": 0, "max_send_attempts": 0, "method": "string", + "push": { + "enabled": true + }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -2442,6 +2445,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "lease_period": 0, "max_send_attempts": 0, "method": "string", + "push": { + "enabled": true + }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -3786,6 +3792,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "lease_period": 0, "max_send_attempts": 0, "method": "string", + "push": { + "enabled": true + }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -3819,6 +3828,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `lease_period` | integer | false | | How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease. | | `max_send_attempts` | integer | false | | The upper limit of attempts to send a notification. | | `method` | string | false | | Which delivery method to use (available options: 'smtp', 'webhook'). | +| `push` | [codersdk.NotificationsPushConfig](#codersdknotificationspushconfig) | false | | Push notification settings. | | `retry_interval` | integer | false | | The minimum time between retries. | | `sync_buffer_size` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | | `sync_interval` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | @@ -3918,6 +3928,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-----------|---------|----------|--------------|-------------| | `enabled` | boolean | false | | | +## codersdk.NotificationsPushConfig + +```json +{ + "enabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|---------|----------|--------------|-------------| +| `enabled` | boolean | false | | | + ## codersdk.NotificationsSettings ```json diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index acaf22e8d7db9..a387c5f5f3385 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1582,3 +1582,14 @@ Enable Coder Inbox. | Default | 5 | The upper limit of attempts to send a notification. + +### --notifications-push-enabled + +| | | +|-------------|------------------------------------------------| +| Type | bool | +| Environment | $CODER_NOTIFICATIONS_PUSH_ENABLED | +| YAML | notifications.push.enabled | +| Default | false | + +Enable push notifications using VAPID. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 68871b89dddd3..5d8cf47dac4a7 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -480,6 +480,10 @@ NOTIFICATIONS / INBOX OPTIONS: --notifications-inbox-enabled bool, $CODER_NOTIFICATIONS_INBOX_ENABLED (default: true) Enable Coder Inbox. +NOTIFICATIONS / PUSH OPTIONS: + --notifications-push-enabled bool, $CODER_NOTIFICATIONS_PUSH_ENABLED (default: false) + Enable push notifications using VAPID. + NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8c8b237fecb0f..3f48d7a80d7af 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1320,6 +1320,7 @@ export interface NotificationsConfig { readonly email: NotificationsEmailConfig; readonly webhook: NotificationsWebhookConfig; readonly inbox: NotificationsInboxConfig; + readonly push: NotificationsPushConfig; } // From codersdk/deployment.go @@ -1355,6 +1356,11 @@ export interface NotificationsInboxConfig { readonly enabled: boolean; } +// From codersdk/deployment.go +export interface NotificationsPushConfig { + readonly enabled: boolean; +} + // From codersdk/notifications.go export interface NotificationsSettings { readonly notifier_paused: boolean; From 1315a46dbf846ea20e821c980059f17f5e4abbce Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:46:18 +0000 Subject: [PATCH 11/40] add test for push notifications being enabled --- coderd/notifications_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index a8a09a1441010..acdd331140fdb 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -6,6 +6,7 @@ import ( "slices" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/serpent" @@ -14,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" + "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -388,6 +390,23 @@ const ( func TestPushNotificationSubscription(t *testing.T) { t.Parallel() + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + PushNotifier: &push.NoopNotifier{ + Msg: assert.AnError.Error(), + }, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + err := memberClient.TestPushNotification(ctx) + require.EqualError(t, err, assert.AnError.Error(), "test push notification should fail") + }) + t.Run("CRUD", func(t *testing.T) { t.Parallel() From 85db78c91096b3252097fdea78f55737002c3e61 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 21:59:17 +0000 Subject: [PATCH 12/40] rename migration --- ...sql => 000311_push_notifications.down.sql} | 0 ...p.sql => 000311_push_notifications.up.sql} | 0 coderd/notifications_test.go | 19 ------------------- 3 files changed, 19 deletions(-) rename coderd/database/migrations/{000310_push_notifications.down.sql => 000311_push_notifications.down.sql} (100%) rename coderd/database/migrations/{000310_push_notifications.up.sql => 000311_push_notifications.up.sql} (100%) diff --git a/coderd/database/migrations/000310_push_notifications.down.sql b/coderd/database/migrations/000311_push_notifications.down.sql similarity index 100% rename from coderd/database/migrations/000310_push_notifications.down.sql rename to coderd/database/migrations/000311_push_notifications.down.sql diff --git a/coderd/database/migrations/000310_push_notifications.up.sql b/coderd/database/migrations/000311_push_notifications.up.sql similarity index 100% rename from coderd/database/migrations/000310_push_notifications.up.sql rename to coderd/database/migrations/000311_push_notifications.up.sql diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index acdd331140fdb..a8a09a1441010 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -6,7 +6,6 @@ import ( "slices" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/serpent" @@ -15,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" - "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -390,23 +388,6 @@ const ( func TestPushNotificationSubscription(t *testing.T) { t.Parallel() - t.Run("Disabled", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{ - PushNotifier: &push.NoopNotifier{ - Msg: assert.AnError.Error(), - }, - }) - - ctx := testutil.Context(t, testutil.WaitShort) - - owner := coderdtest.CreateFirstUser(t, client) - memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - err := memberClient.TestPushNotification(ctx) - require.EqualError(t, err, assert.AnError.Error(), "test push notification should fail") - }) - t.Run("CRUD", func(t *testing.T) { t.Parallel() From 892388abac0606996d319e2690d926ff48dc26aa Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:11:28 +0000 Subject: [PATCH 13/40] skip vapid keypair test on non-postgres; --- cli/server_regenerate_vapid_keypair_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/server_regenerate_vapid_keypair_test.go b/cli/server_regenerate_vapid_keypair_test.go index e3069c9e3db6c..7fcacfc23a2f8 100644 --- a/cli/server_regenerate_vapid_keypair_test.go +++ b/cli/server_regenerate_vapid_keypair_test.go @@ -17,6 +17,9 @@ import ( func TestRegenerateVapidKeypair(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test is only supported on postgres") + } t.Run("NoExistingVAPIDKeys", func(t *testing.T) { t.Parallel() From e600b7d5913e194305db35c055ceb738178e71f2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:28:22 +0000 Subject: [PATCH 14/40] fix some tests --- coderd/database/dbauthz/dbauthz_test.go | 9 ++++++++- .../testdata/fixtures/000311_push_notifications.up.sql | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index bdede0ef7848d..50d8518c3718f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4532,7 +4532,14 @@ func (s *MethodTestSuite) TestSystemFunctions() { check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) s.Run("GetNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Errors(sql.ErrNoRows) + require.NoError(s.T(), db.UpsertNotificationVAPIDKeys(context.Background(), database.UpsertNotificationVAPIDKeysParams{ + VapidPublicKey: "test", + VapidPrivateKey: "test", + })) + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetNotificationVAPIDKeysRow{ + VapidPublicKey: "test", + VapidPrivateKey: "test", + }) })) s.Run("UpsertNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { check.Args(database.UpsertNotificationVAPIDKeysParams{ diff --git a/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql b/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql new file mode 100644 index 0000000000000..a5a3bcc862a6d --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql @@ -0,0 +1,2 @@ +-- VAPID keys lited from coderd/notifications_test.go. +INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) VALUES (gen_random_uuid(), (SELECT id FROM users LIMIT 1), NOW(), 'https://example.com', 'BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=', 'zqbxT6JKstKSY9JKibZLSQ=='); From eef203820fd83ae292972c31e21e9be158559ce1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:29:54 +0000 Subject: [PATCH 15/40] hide subscribe button --- site/src/modules/dashboard/Navbar/NavbarView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 64e3a1e0bba3e..03b9765693857 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -59,11 +59,13 @@ export const NavbarView: FC = ({ + {/* // TODO: styling required here. {subscribed ? ( ) : ( )} + */}
{proxyContextValue && ( From 46f751955b67894200fb0e356f43a0180f667a91 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:38:08 +0000 Subject: [PATCH 16/40] conditionally hide push notification button --- site/src/contexts/usePushNotifications.ts | 13 +++++++++++++ site/src/modules/dashboard/Navbar/NavbarView.tsx | 14 +++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/usePushNotifications.ts index 2915bc8ced494..70348691b77db 100644 --- a/site/src/contexts/usePushNotifications.ts +++ b/site/src/contexts/usePushNotifications.ts @@ -7,6 +7,7 @@ import { useQuery } from "react-query"; interface PushNotifications { readonly subscribed: boolean; readonly loading: boolean; + readonly enabled: boolean; subscribe(): Promise; unsubscribe(): Promise; @@ -19,6 +20,17 @@ export const usePushNotifications = (): PushNotifications => { const [subscribed, setSubscribed] = useState(false); const [loading, setLoading] = useState(true); + // Disable push notifications if the server is not configured to send them. + const enabled = !!buildInfoQuery.data?.push_notifications_public_key; + if (!enabled) { + return { + enabled: false, + subscribed: false, + loading: false, + subscribe: async () => {}, + unsubscribe: async () => {}, + }; + } useEffect(() => { // Check if browser supports push notifications if (!("Notification" in window) || !("serviceWorker" in navigator)) { @@ -95,6 +107,7 @@ export const usePushNotifications = (): PushNotifications => { }; return { + enabled, subscribed, loading: loading || buildInfoQuery.isLoading, subscribe, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 03b9765693857..55061dc8e51c9 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -44,7 +44,7 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { - const { subscribed, loading, subscribe, unsubscribe } = + const { enabled, subscribed, loading, subscribe, unsubscribe } = usePushNotifications(); return ( @@ -59,13 +59,13 @@ export const NavbarView: FC = ({ - {/* // TODO: styling required here. - {subscribed ? ( - - ) : ( - + {enabled && ( + subscribed ? ( + + ) : ( + + ) )} - */}
{proxyContextValue && ( From 440809033d1f7744d85ddf1865d2b616dc4e834b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:48:37 +0000 Subject: [PATCH 17/40] Revert "conditionally hide push notification button" This reverts commit af7b58fa50e208dd9560fdbf971dce551a52bf34. --- site/src/contexts/usePushNotifications.ts | 13 ------------- site/src/modules/dashboard/Navbar/NavbarView.tsx | 14 +++++++------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/usePushNotifications.ts index 70348691b77db..2915bc8ced494 100644 --- a/site/src/contexts/usePushNotifications.ts +++ b/site/src/contexts/usePushNotifications.ts @@ -7,7 +7,6 @@ import { useQuery } from "react-query"; interface PushNotifications { readonly subscribed: boolean; readonly loading: boolean; - readonly enabled: boolean; subscribe(): Promise; unsubscribe(): Promise; @@ -20,17 +19,6 @@ export const usePushNotifications = (): PushNotifications => { const [subscribed, setSubscribed] = useState(false); const [loading, setLoading] = useState(true); - // Disable push notifications if the server is not configured to send them. - const enabled = !!buildInfoQuery.data?.push_notifications_public_key; - if (!enabled) { - return { - enabled: false, - subscribed: false, - loading: false, - subscribe: async () => {}, - unsubscribe: async () => {}, - }; - } useEffect(() => { // Check if browser supports push notifications if (!("Notification" in window) || !("serviceWorker" in navigator)) { @@ -107,7 +95,6 @@ export const usePushNotifications = (): PushNotifications => { }; return { - enabled, subscribed, loading: loading || buildInfoQuery.isLoading, subscribe, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 55061dc8e51c9..03b9765693857 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -44,7 +44,7 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { - const { enabled, subscribed, loading, subscribe, unsubscribe } = + const { subscribed, loading, subscribe, unsubscribe } = usePushNotifications(); return ( @@ -59,13 +59,13 @@ export const NavbarView: FC = ({ - {enabled && ( - subscribed ? ( - - ) : ( - - ) + {/* // TODO: styling required here. + {subscribed ? ( + + ) : ( + )} + */}
{proxyContextValue && ( From 204ab4a2aefc1ded98fe1ca1fdc3d0f609b46791 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 25 Mar 2025 22:53:54 +0000 Subject: [PATCH 18/40] fix data race caused by webpush-go mutating msg []byte --- coderd/notifications/push/push.go | 5 +++-- coderd/notifications/push/push_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index f43e107edf504..414ceb0873481 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -7,6 +7,7 @@ import ( "errors" "io" "net/http" + "slices" "sync" "github.com/SherClockHolmes/webpush-go" @@ -93,8 +94,8 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification subscription := subscription eg.Go(func() error { n.log.Debug(ctx, "dispatching via push", slog.F("subscription", subscription.Endpoint)) - - resp, err := webpush.SendNotificationWithContext(ctx, notificationJSON, &webpush.Subscription{ + cpy := slices.Clone(notificationJSON) // Need to copy as webpush.SendNotificationWithContext modifies the slice. + resp, err := webpush.SendNotificationWithContext(ctx, cpy, &webpush.Subscription{ Endpoint: subscription.Endpoint, Keys: webpush.Keys{ Auth: subscription.EndpointAuthKey, diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go index 3549ee83f9d34..f30605ba774ab 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/notifications/push/push_test.go @@ -179,7 +179,7 @@ func TestPush(t *testing.T) { }, } - err = manager.Dispatch(context.Background(), user.ID, notification) + err = manager.Dispatch(ctx, user.ID, notification) require.NoError(t, err) assert.True(t, okEndpointCalled, "The valid endpoint should be called") assert.True(t, goneEndpointCalled, "The expired endpoint should be called") From 0535ed6c9ee7e950208e4c1508c9effa0e747994 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 11:37:21 +0000 Subject: [PATCH 19/40] Remove deployment config for push notifications in favour of ExperimentWebPush --- cli/server.go | 5 ++- cli/server_regenerate_vapid_keypair.go | 5 ++- cli/testdata/coder_server_--help.golden | 6 --- cli/testdata/server-config.yaml.golden | 4 -- coderd/apidoc/docs.go | 23 +++-------- coderd/apidoc/swagger.json | 23 +++-------- codersdk/deployment.go | 22 +---------- docs/manifest.json | 5 --- docs/reference/api/general.md | 3 -- docs/reference/api/schemas.md | 25 +----------- docs/reference/cli/server.md | 24 +++--------- .../cli/server_regenerate-vapid-keypair.md | 39 ------------------- .../cli/testdata/coder_server_--help.golden | 6 --- site/src/api/typesGenerated.ts | 7 +--- 14 files changed, 25 insertions(+), 172 deletions(-) delete mode 100644 docs/reference/cli/server_regenerate-vapid-keypair.md diff --git a/cli/server.go b/cli/server.go index 1fa054207e702..25d7ee4e9d4f6 100644 --- a/cli/server.go +++ b/cli/server.go @@ -777,7 +777,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } // Manage push notifications. - if options.DeploymentValues.Notifications.Push.Enabled { + experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()) + if experiments.Enabled(codersdk.ExperimentWebPush) { pushNotifier, err := push.New(ctx, &options.Logger, options.Database) if err != nil { options.Logger.Error(ctx, "failed to create push notifier", slog.Error(err)) @@ -789,7 +790,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.PushNotifier = pushNotifier } else { options.PushNotifier = &push.NoopNotifier{ - Msg: "Push notifications are not configured.", + Msg: "Push notifications are disabled. Enable the 'web-push' experiment to use this feature.", } } diff --git a/cli/server_regenerate_vapid_keypair.go b/cli/server_regenerate_vapid_keypair.go index b7ef08502ffd4..8445712bdd840 100644 --- a/cli/server_regenerate_vapid_keypair.go +++ b/cli/server_regenerate_vapid_keypair.go @@ -24,8 +24,9 @@ func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command { regenVapidKeypairPgAuth string ) regenerateVapidKeypairCommand := &serpent.Command{ - Use: "regenerate-vapid-keypair", - Short: "Regenerate the VAPID keypair used for push notifications.", + Use: "regenerate-vapid-keypair", + Short: "Regenerate the VAPID keypair used for push notifications.", + Hidden: true, // Hide this command as it's an experimental feature Handler: func(inv *serpent.Invocation) error { var ( ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 89a995774973d..80779201dc796 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -12,8 +12,6 @@ SUBCOMMANDS: postgres-builtin-serve Run the built-in PostgreSQL deployment. postgres-builtin-url Output the connection URL for the built-in PostgreSQL deployment. - regenerate-vapid-keypair Regenerate the VAPID keypair used for push - notifications. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false) @@ -479,10 +477,6 @@ NOTIFICATIONS / INBOX OPTIONS: --notifications-inbox-enabled bool, $CODER_NOTIFICATIONS_INBOX_ENABLED (default: true) Enable Coder Inbox. -NOTIFICATIONS / PUSH OPTIONS: - --notifications-push-enabled bool, $CODER_NOTIFICATIONS_PUSH_ENABLED (default: false) - Enable push notifications using VAPID. - NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 9fe93b3caf2e6..39ed5eb2c047d 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -681,7 +681,3 @@ notifications: # How often to query the database for queued notifications. # (default: 15s, type: duration) fetchInterval: 15s - push: - # Enable push notifications using VAPID. - # (default: false, type: bool) - enabled: false diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6d9429ba08500..aa9f1afed6480 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11950,19 +11950,22 @@ const docTemplate = `{ "example", "auto-fill-parameters", "notifications", - "workspace-usage" + "workspace-usage", + "web-push" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", + "ExperimentWebPush": "Enables web push notifications through the browser.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentNotifications", - "ExperimentWorkspaceUsage" + "ExperimentWorkspaceUsage", + "ExperimentWebPush" ] }, "codersdk.ExternalAuth": { @@ -12800,14 +12803,6 @@ const docTemplate = `{ "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, - "push": { - "description": "Push notification settings.", - "allOf": [ - { - "$ref": "#/definitions/codersdk.NotificationsPushConfig" - } - ] - }, "retry_interval": { "description": "The minimum time between retries.", "type": "integer" @@ -12925,14 +12920,6 @@ const docTemplate = `{ } } }, - "codersdk.NotificationsPushConfig": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } - }, "codersdk.NotificationsSettings": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 285a0eadc5ce3..ad044e4e98f62 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10700,19 +10700,22 @@ "example", "auto-fill-parameters", "notifications", - "workspace-usage" + "workspace-usage", + "web-push" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", + "ExperimentWebPush": "Enables web push notifications through the browser.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentNotifications", - "ExperimentWorkspaceUsage" + "ExperimentWorkspaceUsage", + "ExperimentWebPush" ] }, "codersdk.ExternalAuth": { @@ -11501,14 +11504,6 @@ "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, - "push": { - "description": "Push notification settings.", - "allOf": [ - { - "$ref": "#/definitions/codersdk.NotificationsPushConfig" - } - ] - }, "retry_interval": { "description": "The minimum time between retries.", "type": "integer" @@ -11626,14 +11621,6 @@ } } }, - "codersdk.NotificationsPushConfig": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - } - }, "codersdk.NotificationsSettings": { "type": "object", "properties": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 1c016512164d7..60d232f77c46a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -700,12 +700,6 @@ type NotificationsConfig struct { Webhook NotificationsWebhookConfig `json:"webhook" typescript:",notnull"` // Inbox settings. Inbox NotificationsInboxConfig `json:"inbox" typescript:",notnull"` - // Push notification settings. - Push NotificationsPushConfig `json:"push" typescript:",notnull"` -} - -type NotificationsPushConfig struct { - Enabled serpent.Bool `json:"enabled" typescript:",notnull"` } // Are either of the notification methods enabled? @@ -1007,11 +1001,6 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Parent: &deploymentGroupNotifications, YAML: "inbox", } - deploymentGroupNotificationsPush = serpent.Group{ - Name: "Push", - Parent: &deploymentGroupNotifications, - YAML: "push", - } ) httpAddress := serpent.Option{ @@ -2980,16 +2969,6 @@ Write out the current server config as YAML to stdout.`, Hidden: true, // Hidden because most operators should not need to modify this. }, // Push notifications. - { - Name: "Notifications: Push: Enabled", - Description: "Enable push notifications using VAPID.", - Flag: "notifications-push-enabled", - Env: "CODER_NOTIFICATIONS_PUSH_ENABLED", - Value: &c.Notifications.Push.Enabled, - Default: "false", - Group: &deploymentGroupNotificationsPush, - YAML: "enabled", - }, } return opts @@ -3214,6 +3193,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. + ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ) // ExperimentsAll should include all experiments that are safe for diff --git a/docs/manifest.json b/docs/manifest.json index c87610f69886d..7b15d7ac81754 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1320,11 +1320,6 @@ "description": "Output the connection URL for the built-in PostgreSQL deployment.", "path": "reference/cli/server_postgres-builtin-url.md" }, - { - "title": "server regenerate-vapid-keypair", - "description": "Regenerate the VAPID keypair used for push notifications.", - "path": "reference/cli/server_regenerate-vapid-keypair.md" - }, { "title": "show", "description": "Display details of a workspace's resources and agents", diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 1fce46fec6c77..2339fc17d0800 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -301,9 +301,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "lease_period": 0, "max_send_attempts": 0, "method": "string", - "push": { - "enabled": true - }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ca1b3edae1d06..51c29dbf200f9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1966,9 +1966,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "lease_period": 0, "max_send_attempts": 0, "method": "string", - "push": { - "enabled": true - }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -2445,9 +2442,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "lease_period": 0, "max_send_attempts": 0, "method": "string", - "push": { - "enabled": true - }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -2826,6 +2820,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `auto-fill-parameters` | | `notifications` | | `workspace-usage` | +| `web-push` | ## codersdk.ExternalAuth @@ -3792,9 +3787,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "lease_period": 0, "max_send_attempts": 0, "method": "string", - "push": { - "enabled": true - }, "retry_interval": 0, "sync_buffer_size": 0, "sync_interval": 0, @@ -3828,7 +3820,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `lease_period` | integer | false | | How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease. | | `max_send_attempts` | integer | false | | The upper limit of attempts to send a notification. | | `method` | string | false | | Which delivery method to use (available options: 'smtp', 'webhook'). | -| `push` | [codersdk.NotificationsPushConfig](#codersdknotificationspushconfig) | false | | Push notification settings. | | `retry_interval` | integer | false | | The minimum time between retries. | | `sync_buffer_size` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | | `sync_interval` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | @@ -3928,20 +3919,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-----------|---------|----------|--------------|-------------| | `enabled` | boolean | false | | | -## codersdk.NotificationsPushConfig - -```json -{ - "enabled": true -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|-----------|---------|----------|--------------|-------------| -| `enabled` | boolean | false | | | - ## codersdk.NotificationsSettings ```json diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index a387c5f5f3385..888e569f9d5bc 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -11,13 +11,12 @@ coder server [flags] ## Subcommands -| Name | Purpose | -|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| -| [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | -| [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | -| [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | -| [regenerate-vapid-keypair](./server_regenerate-vapid-keypair.md) | Regenerate the VAPID keypair used for push notifications. | -| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. | +| Name | Purpose | +|---------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | +| [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | +| [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | +| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. | ## Options @@ -1582,14 +1581,3 @@ Enable Coder Inbox. | Default | 5 | The upper limit of attempts to send a notification. - -### --notifications-push-enabled - -| | | -|-------------|------------------------------------------------| -| Type | bool | -| Environment | $CODER_NOTIFICATIONS_PUSH_ENABLED | -| YAML | notifications.push.enabled | -| Default | false | - -Enable push notifications using VAPID. diff --git a/docs/reference/cli/server_regenerate-vapid-keypair.md b/docs/reference/cli/server_regenerate-vapid-keypair.md deleted file mode 100644 index 3446a60e133d1..0000000000000 --- a/docs/reference/cli/server_regenerate-vapid-keypair.md +++ /dev/null @@ -1,39 +0,0 @@ - -# server regenerate-vapid-keypair - -Regenerate the VAPID keypair used for push notifications. - -## Usage - -```console -coder server regenerate-vapid-keypair [flags] -``` - -## Options - -### -y, --yes - -| | | -|------|-------------------| -| Type | bool | - -Bypass prompts. - -### --postgres-url - -| | | -|-------------|---------------------------------------| -| Type | string | -| Environment | $CODER_PG_CONNECTION_URL | - -URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). - -### --postgres-connection-auth - -| | | -|-------------|----------------------------------------| -| Type | password\|awsiamrds | -| Environment | $CODER_PG_CONNECTION_AUTH | -| Default | password | - -Type of auth to use when connecting to postgres. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 5d8cf47dac4a7..8ad6839c7a635 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -13,8 +13,6 @@ SUBCOMMANDS: postgres-builtin-serve Run the built-in PostgreSQL deployment. postgres-builtin-url Output the connection URL for the built-in PostgreSQL deployment. - regenerate-vapid-keypair Regenerate the VAPID keypair used for push - notifications. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false) @@ -480,10 +478,6 @@ NOTIFICATIONS / INBOX OPTIONS: --notifications-inbox-enabled bool, $CODER_NOTIFICATIONS_INBOX_ENABLED (default: true) Enable Coder Inbox. -NOTIFICATIONS / PUSH OPTIONS: - --notifications-push-enabled bool, $CODER_NOTIFICATIONS_PUSH_ENABLED (default: false) - Enable push notifications using VAPID. - NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT The endpoint to which to send webhooks. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3f48d7a80d7af..db00007e84cc6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -751,6 +751,7 @@ export type Experiment = | "auto-fill-parameters" | "example" | "notifications" + | "web-push" | "workspace-usage"; // From codersdk/deployment.go @@ -1320,7 +1321,6 @@ export interface NotificationsConfig { readonly email: NotificationsEmailConfig; readonly webhook: NotificationsWebhookConfig; readonly inbox: NotificationsInboxConfig; - readonly push: NotificationsPushConfig; } // From codersdk/deployment.go @@ -1356,11 +1356,6 @@ export interface NotificationsInboxConfig { readonly enabled: boolean; } -// From codersdk/deployment.go -export interface NotificationsPushConfig { - readonly enabled: boolean; -} - // From codersdk/notifications.go export interface NotificationsSettings { readonly notifier_paused: boolean; From 9f1f4f9cf60bc6769ed1851dfbc01f413151157d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 12:33:51 +0000 Subject: [PATCH 20/40] push notification -> webpush --- cli/server_regenerate_vapid_keypair_test.go | 14 +- coderd/apidoc/docs.go | 48 ++-- coderd/apidoc/swagger.json | 48 ++-- coderd/coderd.go | 24 +- coderd/database/dbauthz/dbauthz.go | 106 ++++----- coderd/database/dbauthz/dbauthz_test.go | 38 +-- coderd/database/dbgen/dbgen.go | 7 +- coderd/database/dbmem/dbmem.go | 201 ++++++++-------- coderd/database/dbmetrics/querymetrics.go | 98 ++++---- coderd/database/dbmock/dbmock.go | 202 ++++++++-------- coderd/database/dump.sql | 30 +-- coderd/database/foreign_key_constraint.go | 2 +- .../000311_push_notifications.down.sql | 2 - .../000311_webpush_subscriptions.down.sql | 2 + ...ql => 000311_webpush_subscriptions.up.sql} | 4 +- .../fixtures/000311_push_notifications.up.sql | 2 - .../000311_webpush_subscriptions.up.sql | 2 + coderd/database/models.go | 18 +- coderd/database/querier.go | 20 +- coderd/database/queries.sql.go | 220 +++++++++--------- coderd/database/queries/notifications.sql | 24 +- coderd/database/queries/siteconfig.sql | 12 +- coderd/database/unique_constraint.go | 2 +- coderd/notifications.go | 21 +- coderd/notifications/push/push.go | 18 +- coderd/notifications/push/push_test.go | 56 ++--- coderd/notifications_test.go | 10 +- coderd/rbac/object_gen.go | 14 +- coderd/rbac/policy/policy.go | 8 +- coderd/rbac/roles_test.go | 6 +- codersdk/deployment.go | 4 +- codersdk/notifications.go | 28 +-- codersdk/rbacresources_gen.go | 4 +- docs/reference/api/general.md | 2 +- docs/reference/api/members.md | 10 +- docs/reference/api/notifications.md | 16 +- docs/reference/api/schemas.md | 66 +++--- site/src/api/api.ts | 2 +- site/src/api/rbacresourcesGenerated.ts | 8 +- site/src/api/typesGenerated.ts | 6 +- 40 files changed, 701 insertions(+), 704 deletions(-) delete mode 100644 coderd/database/migrations/000311_push_notifications.down.sql create mode 100644 coderd/database/migrations/000311_webpush_subscriptions.down.sql rename coderd/database/migrations/{000311_push_notifications.up.sql => 000311_webpush_subscriptions.up.sql} (80%) delete mode 100644 coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql diff --git a/cli/server_regenerate_vapid_keypair_test.go b/cli/server_regenerate_vapid_keypair_test.go index 7fcacfc23a2f8..cbaff3681df11 100644 --- a/cli/server_regenerate_vapid_keypair_test.go +++ b/cli/server_regenerate_vapid_keypair_test.go @@ -36,7 +36,7 @@ func TestRegenerateVapidKeypair(t *testing.T) { db := database.New(sqlDB) // Ensure there is no existing VAPID keypair. - rows, err := db.GetNotificationVAPIDKeys(ctx) + rows, err := db.GetWebpushVAPIDKeys(ctx) require.NoError(t, err) require.Empty(t, rows) @@ -48,13 +48,13 @@ func TestRegenerateVapidKeypair(t *testing.T) { clitest.Start(t, inv) pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...") - pty.ExpectMatchContext(ctx, "This will delete all existing push notification subscriptions.") + pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.") pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)") pty.WriteLine("y") pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.") // Ensure the VAPID keypair was created. - keys, err := db.GetNotificationVAPIDKeys(ctx) + keys, err := db.GetWebpushVAPIDKeys(ctx) require.NoError(t, err) require.NotEmpty(t, keys.VapidPublicKey) require.NotEmpty(t, keys.VapidPrivateKey) @@ -79,7 +79,7 @@ func TestRegenerateVapidKeypair(t *testing.T) { u := dbgen.User(t, db, database.User{}) // Insert a few fake push subscriptions for each user. for j := 0; j < 10; j++ { - _ = dbgen.NotificationPushSubscription(t, db, database.InsertNotificationPushSubscriptionParams{ + _ = dbgen.WebpushSubscription(t, db, database.InsertWebpushSubscriptionParams{ UserID: u.ID, }) } @@ -93,20 +93,20 @@ func TestRegenerateVapidKeypair(t *testing.T) { clitest.Start(t, inv) pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...") - pty.ExpectMatchContext(ctx, "This will delete all existing push notification subscriptions.") + pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.") pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)") pty.WriteLine("y") pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.") // Ensure the VAPID keypair was created. - keys, err := db.GetNotificationVAPIDKeys(ctx) + keys, err := db.GetWebpushVAPIDKeys(ctx) require.NoError(t, err) require.NotEmpty(t, keys.VapidPublicKey) require.NotEmpty(t, keys.VapidPrivateKey) // Ensure the push subscriptions were deleted. var count int64 - rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM notification_push_subscriptions") + rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM webpush_subscriptions") require.NoError(t, err) t.Cleanup(func() { _ = rows.Close() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index aa9f1afed6480..4b24973457aaf 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7240,12 +7240,12 @@ const docTemplate = `{ "operationId": "create-user-push-notification-subscription", "parameters": [ { - "description": "Push notification subscription", + "description": "Webpush subscription", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PushNotificationSubscription" + "$ref": "#/definitions/codersdk.WebpushSubscription" } }, { @@ -7283,7 +7283,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.DeletePushNotificationSubscription" + "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" } }, { @@ -10815,10 +10815,6 @@ const docTemplate = `{ "description": "ProvisionerAPIVersion is the current version of the Provisioner API", "type": "string" }, - "push_notifications_public_key": { - "description": "PushNotificationsPublicKey is the public key for push notifications.", - "type": "string" - }, "telemetry": { "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", "type": "boolean" @@ -10831,6 +10827,10 @@ const docTemplate = `{ "description": "Version returns the semantic version of the build.", "type": "string" }, + "webpush_public_key": { + "description": "WebPushPublicKey is the public key for push notifications.", + "type": "string" + }, "workspace_proxy": { "type": "boolean" } @@ -11607,7 +11607,7 @@ const docTemplate = `{ } } }, - "codersdk.DeletePushNotificationSubscription": { + "codersdk.DeleteWebpushSubscription": { "type": "object", "properties": { "endpoint": { @@ -14134,20 +14134,6 @@ const docTemplate = `{ "ProxyUnregistered" ] }, - "codersdk.PushNotificationSubscription": { - "type": "object", - "properties": { - "auth_key": { - "type": "string" - }, - "endpoint": { - "type": "string" - }, - "p256dh_key": { - "type": "string" - } - } - }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": [ @@ -14233,7 +14219,6 @@ const docTemplate = `{ "license", "notification_message", "notification_preference", - "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -14247,6 +14232,7 @@ const docTemplate = `{ "tailnet_coordinator", "template", "user", + "webpush_subscription", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", @@ -14271,7 +14257,6 @@ const docTemplate = `{ "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", - "ResourceNotificationPushSubscription", "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", @@ -14285,6 +14270,7 @@ const docTemplate = `{ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentResourceMonitor", @@ -16117,6 +16103,20 @@ const docTemplate = `{ } } }, + "codersdk.WebpushSubscription": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "p256dh_key": { + "type": "string" + } + } + }, "codersdk.Workspace": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ad044e4e98f62..ec8d0c6ae8680 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6397,12 +6397,12 @@ "operationId": "create-user-push-notification-subscription", "parameters": [ { - "description": "Push notification subscription", + "description": "Webpush subscription", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PushNotificationSubscription" + "$ref": "#/definitions/codersdk.WebpushSubscription" } }, { @@ -6436,7 +6436,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.DeletePushNotificationSubscription" + "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" } }, { @@ -9627,10 +9627,6 @@ "description": "ProvisionerAPIVersion is the current version of the Provisioner API", "type": "string" }, - "push_notifications_public_key": { - "description": "PushNotificationsPublicKey is the public key for push notifications.", - "type": "string" - }, "telemetry": { "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", "type": "boolean" @@ -9643,6 +9639,10 @@ "description": "Version returns the semantic version of the build.", "type": "string" }, + "webpush_public_key": { + "description": "WebPushPublicKey is the public key for push notifications.", + "type": "string" + }, "workspace_proxy": { "type": "boolean" } @@ -10361,7 +10361,7 @@ } } }, - "codersdk.DeletePushNotificationSubscription": { + "codersdk.DeleteWebpushSubscription": { "type": "object", "properties": { "endpoint": { @@ -12793,20 +12793,6 @@ "ProxyUnregistered" ] }, - "codersdk.PushNotificationSubscription": { - "type": "object", - "properties": { - "auth_key": { - "type": "string" - }, - "endpoint": { - "type": "string" - }, - "p256dh_key": { - "type": "string" - } - } - }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": ["deadline"], @@ -12887,7 +12873,6 @@ "license", "notification_message", "notification_preference", - "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -12901,6 +12886,7 @@ "tailnet_coordinator", "template", "user", + "webpush_subscription", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", @@ -12925,7 +12911,6 @@ "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", - "ResourceNotificationPushSubscription", "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", @@ -12939,6 +12924,7 @@ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentResourceMonitor", @@ -14678,6 +14664,20 @@ } } }, + "codersdk.WebpushSubscription": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "p256dh_key": { + "type": "string" + } + } + }, "codersdk.Workspace": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 160a462af116f..71a9504363608 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -577,16 +577,16 @@ func New(options *Options) *API { api.AppearanceFetcher.Store(&f) api.PortSharer.Store(&portsharing.DefaultPortSharer) buildInfo := codersdk.BuildInfoResponse{ - ExternalURL: buildinfo.ExternalURL(), - Version: buildinfo.Version(), - AgentAPIVersion: AgentAPIVersionREST, - ProvisionerAPIVersion: proto.CurrentVersion.String(), - DashboardURL: api.AccessURL.String(), - WorkspaceProxy: false, - UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), - DeploymentID: api.DeploymentID, - PushNotificationsPublicKey: api.PushNotifier.PublicKey(), - Telemetry: api.Telemetry.Enabled(), + ExternalURL: buildinfo.ExternalURL(), + Version: buildinfo.Version(), + AgentAPIVersion: AgentAPIVersionREST, + ProvisionerAPIVersion: proto.CurrentVersion.String(), + DashboardURL: api.AccessURL.String(), + WorkspaceProxy: false, + UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), + DeploymentID: api.DeploymentID, + WebPushPublicKey: api.PushNotifier.PublicKey(), + Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ BinFS: binFS, @@ -1201,8 +1201,8 @@ func New(options *Options) *API { r.Put("/", api.putUserNotificationPreferences) }) r.Route("/push", func(r chi.Router) { - r.Post("/subscription", api.postUserPushNotificationSubscription) - r.Delete("/subscription", api.deleteUserPushNotificationSubscription) + r.Post("/subscription", api.postUserWebpushSubscription) + r.Delete("/subscription", api.deleteUserWebpushSubscription) r.Post("/test", api.postUserPushNotificationTest) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6b3b0208226fb..c0f4527f2b7c3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -281,10 +281,10 @@ var ( Identifier: rbac.RoleIdentifier{Name: "notifier"}, DisplayName: "Notifier", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, - rbac.ResourceNotificationPushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys + rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, + rbac.ResourceWebpushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -1033,6 +1033,20 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole) return nil } +func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.GetWebpushVAPIDKeysRow{}, err + } + return q.db.GetWebpushVAPIDKeys(ctx) +} + +func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertWebpushVAPIDKeys(ctx, arg) +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -1164,13 +1178,6 @@ func (q *querier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) e return q.db.DeleteAPIKeysByUserID(ctx, userID) } -func (q *querier) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceNotificationPushSubscription); err != nil { - return err - } - return q.db.DeleteAllNotificationPushSubscriptions(ctx) -} - func (q *querier) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1185,6 +1192,13 @@ func (q *querier) DeleteAllTailnetTunnels(ctx context.Context, arg database.Dele return q.db.DeleteAllTailnetTunnels(ctx, arg) } +func (q *querier) DeleteAllWebpushSubscriptions(ctx context.Context) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription); err != nil { + return err + } + return q.db.DeleteAllWebpushSubscriptions(ctx) +} + func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { // TODO: This is not 100% correct because it omits apikey IDs. err := q.authorizeContext(ctx, policy.ActionDelete, @@ -1254,20 +1268,6 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { return id, nil } -func (q *querier) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceNotificationPushSubscription.WithOwner(arg.UserID.String())); err != nil { - return err - } - return q.db.DeleteNotificationPushSubscriptionByEndpoint(ctx, arg) -} - -func (q *querier) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { - return err - } - return q.db.DeleteNotificationPushSubscriptions(ctx, ids) -} - func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2App); err != nil { return err @@ -1404,6 +1404,20 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { + return err + } + return q.db.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) +} + +func (q *querier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return err + } + return q.db.DeleteWebpushSubscriptions(ctx, ids) +} + func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { @@ -1890,13 +1904,6 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } -func (q *querier) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationPushSubscription.WithOwner(userID.String())); err != nil { - return nil, err - } - return q.db.GetNotificationPushSubscriptionsByUserID(ctx, userID) -} - func (q *querier) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return database.NotificationReportGeneratorLog{}, err @@ -1921,13 +1928,6 @@ func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind datab return nil, sql.ErrNoRows } -func (q *querier) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.GetNotificationVAPIDKeysRow{}, err - } - return q.db.GetNotificationVAPIDKeys(ctx) -} - func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) { // No authz checks return q.db.GetNotificationsSettings(ctx) @@ -2700,6 +2700,13 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } +func (q *querier) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWebpushSubscription.WithOwner(userID.String())); err != nil { + return nil, err + } + return q.db.GetWebpushSubscriptionsByUserID(ctx, userID) +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { @@ -3238,13 +3245,6 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi return q.db.InsertMissingGroups(ctx, arg) } -func (q *querier) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceNotificationPushSubscription.WithOwner(arg.UserID.String())); err != nil { - return database.NotificationPushSubscription{}, err - } - return q.db.InsertNotificationPushSubscription(ctx, arg) -} - func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -3464,6 +3464,13 @@ func (q *querier) InsertVolumeResourceMonitor(ctx context.Context, arg database. return q.db.InsertVolumeResourceMonitor(ctx, arg) } +func (q *querier) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { + return database.WebpushSubscription{}, err + } + return q.db.InsertWebpushSubscription(ctx, arg) +} + func (q *querier) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) { obj := rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID) tpl, err := q.GetTemplateByID(ctx, arg.TemplateID) @@ -4619,13 +4626,6 @@ func (q *querier) UpsertNotificationReportGeneratorLog(ctx context.Context, arg return q.db.UpsertNotificationReportGeneratorLog(ctx, arg) } -func (q *querier) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertNotificationVAPIDKeys(ctx, arg) -} - func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 50d8518c3718f..70c2a33443a16 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4531,18 +4531,18 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("UpsertOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { - require.NoError(s.T(), db.UpsertNotificationVAPIDKeys(context.Background(), database.UpsertNotificationVAPIDKeysParams{ + s.Run("GetWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { + require.NoError(s.T(), db.UpsertWebpushVAPIDKeys(context.Background(), database.UpsertWebpushVAPIDKeysParams{ VapidPublicKey: "test", VapidPrivateKey: "test", })) - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetNotificationVAPIDKeysRow{ + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetWebpushVAPIDKeysRow{ VapidPublicKey: "test", VapidPrivateKey: "test", }) })) - s.Run("UpsertNotificationVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertNotificationVAPIDKeysParams{ + s.Run("UpsertWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertWebpushVAPIDKeysParams{ VapidPublicKey: "test", VapidPrivateKey: "test", }).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) @@ -4584,37 +4584,37 @@ func (s *MethodTestSuite) TestNotifications() { }).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) })) - // Notification push subscriptions - s.Run("GetNotificationPushSubscriptionsByUserID", s.Subtest(func(db database.Store, check *expects) { + // webpush subscriptions + s.Run("GetWebpushSubscriptionsByUserID", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) - check.Args(user.ID).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionRead) + check.Args(user.ID).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionRead) })) - s.Run("InsertNotificationPushSubscription", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertWebpushSubscription", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertNotificationPushSubscriptionParams{ + check.Args(database.InsertWebpushSubscriptionParams{ UserID: user.ID, - }).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) + }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) })) - s.Run("DeleteNotificationPushSubscriptions", s.Subtest(func(db database.Store, check *expects) { + s.Run("DeleteWebpushSubscriptions", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) - push := dbgen.NotificationPushSubscription(s.T(), db, database.InsertNotificationPushSubscriptionParams{ + push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ UserID: user.ID, }) check.Args([]uuid.UUID{push.ID}).Asserts(rbac.ResourceSystem, policy.ActionDelete) })) - s.Run("DeleteNotificationPushSubscriptionByEndpoint", s.Subtest(func(db database.Store, check *expects) { + s.Run("DeleteWebpushSubscriptionByUserIDAndEndpoint", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) - push := dbgen.NotificationPushSubscription(s.T(), db, database.InsertNotificationPushSubscriptionParams{ + push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ UserID: user.ID, }) - check.Args(database.DeleteNotificationPushSubscriptionByEndpointParams{ + check.Args(database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ UserID: user.ID, Endpoint: push.Endpoint, - }).Asserts(rbac.ResourceNotificationPushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) + }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) })) - s.Run("DeleteAllNotificationPushSubscriptions", s.Subtest(func(_ database.Store, check *expects) { + s.Run("DeleteAllWebpushSubscriptions", s.Subtest(func(_ database.Store, check *expects) { check.Args(). - Asserts(rbac.ResourceNotificationPushSubscription, policy.ActionDelete) + Asserts(rbac.ResourceWebpushSubscription, policy.ActionDelete) })) // Notification templates diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1a38c4ffff179..c43bdfba2b8ca 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -479,16 +479,15 @@ func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInbo return notification } -func NotificationPushSubscription(t testing.TB, db database.Store, orig database.InsertNotificationPushSubscriptionParams) database.NotificationPushSubscription { - subscription, err := db.InsertNotificationPushSubscription(genCtx, database.InsertNotificationPushSubscriptionParams{ - ID: takeFirst(orig.ID, uuid.New()), +func WebpushSubscription(t testing.TB, db database.Store, orig database.InsertWebpushSubscriptionParams) database.WebpushSubscription { + subscription, err := db.InsertWebpushSubscription(genCtx, database.InsertWebpushSubscriptionParams{ CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UserID: takeFirst(orig.UserID, uuid.New()), Endpoint: takeFirst(orig.Endpoint, testutil.GetRandomName(t)), EndpointP256dhKey: takeFirst(orig.EndpointP256dhKey, testutil.GetRandomName(t)), EndpointAuthKey: takeFirst(orig.EndpointAuthKey, testutil.GetRandomName(t)), }) - require.NoError(t, err, "insert notification push subscription") + require.NoError(t, err, "insert webpush subscription") return subscription } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 190dcb6b6a2aa..eaa1da65d1faa 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -227,7 +227,6 @@ type data struct { notificationMessages []database.NotificationMessage notificationPreferences []database.NotificationPreference notificationReportGeneratorLogs []database.NotificationReportGeneratorLog - notificationPushSubscriptions []database.NotificationPushSubscription inboxNotifications []database.InboxNotification oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret @@ -247,6 +246,7 @@ type data struct { templates []database.TemplateTable templateUsageStats []database.TemplateUsageStat userConfigs []database.UserConfig + webpushSubscriptions []database.WebpushSubscription workspaceAgents []database.WorkspaceAgent workspaceAgentMetadata []database.WorkspaceAgentMetadatum workspaceAgentLogs []database.WorkspaceAgentLog @@ -290,8 +290,8 @@ type data struct { lastLicenseID int32 defaultProxyDisplayName string defaultProxyIconURL string - notificationsPushVAPIDPublicKey string - notificationsPushVAPIDPrivateKey string + webpushVAPIDPublicKey string + webpushVAPIDPrivateKey string userStatusChanges []database.UserStatusChange telemetryItems []database.TelemetryItem presets []database.TemplateVersionPreset @@ -1378,6 +1378,34 @@ func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue( return jobs, nil } +func (q *FakeQuerier) GetWebpushVAPIDKeys(_ context.Context) (database.GetWebpushVAPIDKeysRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.webpushVAPIDPublicKey == "" && q.webpushVAPIDPrivateKey == "" { + return database.GetWebpushVAPIDKeysRow{}, sql.ErrNoRows + } + + return database.GetWebpushVAPIDKeysRow{ + VapidPublicKey: q.webpushVAPIDPublicKey, + VapidPrivateKey: q.webpushVAPIDPrivateKey, + }, nil +} + +func (q *FakeQuerier) UpsertWebpushVAPIDKeys(_ context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + q.webpushVAPIDPublicKey = arg.VapidPublicKey + q.webpushVAPIDPrivateKey = arg.VapidPrivateKey + return nil +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -1838,14 +1866,6 @@ func (q *FakeQuerier) DeleteAPIKeysByUserID(_ context.Context, userID uuid.UUID) return nil } -func (q *FakeQuerier) DeleteAllNotificationPushSubscriptions(_ context.Context) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - q.notificationPushSubscriptions = make([]database.NotificationPushSubscription, 0) - return nil -} - func (*FakeQuerier) DeleteAllTailnetClientSubscriptions(_ context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -1864,6 +1884,14 @@ func (*FakeQuerier) DeleteAllTailnetTunnels(_ context.Context, arg database.Dele return ErrUnimplemented } +func (q *FakeQuerier) DeleteAllWebpushSubscriptions(_ context.Context) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.webpushSubscriptions = make([]database.WebpushSubscription, 0) + return nil +} + func (q *FakeQuerier) DeleteApplicationConnectAPIKeysByUserID(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -2009,36 +2037,6 @@ func (q *FakeQuerier) DeleteLicense(_ context.Context, id int32) (int32, error) return 0, sql.ErrNoRows } -func (q *FakeQuerier) DeleteNotificationPushSubscriptionByEndpoint(_ context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, subscription := range q.notificationPushSubscriptions { - if subscription.UserID == arg.UserID && subscription.Endpoint == arg.Endpoint { - q.notificationPushSubscriptions[i] = q.notificationPushSubscriptions[len(q.notificationPushSubscriptions)-1] - q.notificationPushSubscriptions = q.notificationPushSubscriptions[:len(q.notificationPushSubscriptions)-1] - return nil - } - } - return sql.ErrNoRows -} - -func (q *FakeQuerier) DeleteNotificationPushSubscriptions(_ context.Context, ids []uuid.UUID) error { - for i, subscription := range q.notificationPushSubscriptions { - if slices.Contains(ids, subscription.ID) { - q.notificationPushSubscriptions[i] = q.notificationPushSubscriptions[len(q.notificationPushSubscriptions)-1] - q.notificationPushSubscriptions = q.notificationPushSubscriptions[:len(q.notificationPushSubscriptions)-1] - return nil - } - } - return sql.ErrNoRows -} - func (q *FakeQuerier) DeleteOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -2463,6 +2461,36 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa return database.DeleteTailnetTunnelRow{}, ErrUnimplemented } +func (q *FakeQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(_ context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, subscription := range q.webpushSubscriptions { + if subscription.UserID == arg.UserID && subscription.Endpoint == arg.Endpoint { + q.webpushSubscriptions[i] = q.webpushSubscriptions[len(q.webpushSubscriptions)-1] + q.webpushSubscriptions = q.webpushSubscriptions[:len(q.webpushSubscriptions)-1] + return nil + } + } + return sql.ErrNoRows +} + +func (q *FakeQuerier) DeleteWebpushSubscriptions(_ context.Context, ids []uuid.UUID) error { + for i, subscription := range q.webpushSubscriptions { + if slices.Contains(ids, subscription.ID) { + q.webpushSubscriptions[i] = q.webpushSubscriptions[len(q.webpushSubscriptions)-1] + q.webpushSubscriptions = q.webpushSubscriptions[:len(q.webpushSubscriptions)-1] + return nil + } + } + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteWorkspaceAgentPortShare(_ context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { err := validateDatabaseType(arg) if err != nil { @@ -3807,20 +3835,6 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } -func (q *FakeQuerier) GetNotificationPushSubscriptionsByUserID(_ context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - out := make([]database.NotificationPushSubscription, 0) - for _, subscription := range q.notificationPushSubscriptions { - if subscription.UserID == userID { - out = append(out, subscription) - } - } - - return out, nil -} - func (q *FakeQuerier) GetNotificationReportGeneratorLogByTemplate(_ context.Context, templateID uuid.UUID) (database.NotificationReportGeneratorLog, error) { err := validateDatabaseType(templateID) if err != nil { @@ -3850,20 +3864,6 @@ func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database return nil, ErrUnimplemented } -func (q *FakeQuerier) GetNotificationVAPIDKeys(_ context.Context) (database.GetNotificationVAPIDKeysRow, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - if q.notificationsPushVAPIDPublicKey == "" && q.notificationsPushVAPIDPrivateKey == "" { - return database.GetNotificationVAPIDKeysRow{}, sql.ErrNoRows - } - - return database.GetNotificationVAPIDKeysRow{ - VapidPublicKey: q.notificationsPushVAPIDPublicKey, - VapidPrivateKey: q.notificationsPushVAPIDPrivateKey, - }, nil -} - func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -6786,6 +6786,20 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } +func (q *FakeQuerier) GetWebpushSubscriptionsByUserID(_ context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.WebpushSubscription, 0) + for _, subscription := range q.webpushSubscriptions { + if subscription.UserID == userID { + out = append(out, subscription) + } + } + + return out, nil +} + func (q *FakeQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8525,20 +8539,6 @@ func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.Insert return newGroups, nil } -func (q *FakeQuerier) InsertNotificationPushSubscription(_ context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { - err := validateDatabaseType(arg) - if err != nil { - return database.NotificationPushSubscription{}, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - subscription := database.NotificationPushSubscription(arg) - q.notificationPushSubscriptions = append(q.notificationPushSubscriptions, subscription) - return subscription, nil -} - func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { err := validateDatabaseType(arg) if err != nil { @@ -9227,6 +9227,27 @@ func (q *FakeQuerier) InsertVolumeResourceMonitor(_ context.Context, arg databas return monitor, nil } +func (q *FakeQuerier) InsertWebpushSubscription(_ context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WebpushSubscription{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + newSub := database.WebpushSubscription{ + ID: uuid.New(), + UserID: arg.UserID, + CreatedAt: arg.CreatedAt, + Endpoint: arg.Endpoint, + EndpointP256dhKey: arg.EndpointP256dhKey, + EndpointAuthKey: arg.EndpointAuthKey, + } + q.webpushSubscriptions = append(q.webpushSubscriptions, newSub) + return newSub, nil +} + func (q *FakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceTable{}, err @@ -11805,20 +11826,6 @@ func (q *FakeQuerier) UpsertNotificationReportGeneratorLog(_ context.Context, ar return nil } -func (q *FakeQuerier) UpsertNotificationVAPIDKeys(_ context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - q.notificationsPushVAPIDPublicKey = arg.VapidPublicKey - q.notificationsPushVAPIDPrivateKey = arg.VapidPrivateKey - return nil -} - func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 84a7f97b02cc8..18bd9b109d86a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -88,6 +88,20 @@ func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) return r0 } +func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { + start := time.Now() + r0, r1 := m.s.GetWebpushVAPIDKeys(ctx) + m.queryLatencies.WithLabelValues("GetWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + start := time.Now() + r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() err := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -207,13 +221,6 @@ func (m queryMetricsStore) DeleteAPIKeysByUserID(ctx context.Context, userID uui return err } -func (m queryMetricsStore) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { - start := time.Now() - r0 := m.s.DeleteAllNotificationPushSubscriptions(ctx) - m.queryLatencies.WithLabelValues("DeleteAllNotificationPushSubscriptions").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { start := time.Now() r0 := m.s.DeleteAllTailnetClientSubscriptions(ctx, arg) @@ -228,6 +235,13 @@ func (m queryMetricsStore) DeleteAllTailnetTunnels(ctx context.Context, arg data return r0 } +func (m queryMetricsStore) DeleteAllWebpushSubscriptions(ctx context.Context) error { + start := time.Now() + r0 := m.s.DeleteAllWebpushSubscriptions(ctx) + m.queryLatencies.WithLabelValues("DeleteAllWebpushSubscriptions").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { start := time.Now() err := m.s.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) @@ -291,20 +305,6 @@ func (m queryMetricsStore) DeleteLicense(ctx context.Context, id int32) (int32, return licenseID, err } -func (m queryMetricsStore) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { - start := time.Now() - r0 := m.s.DeleteNotificationPushSubscriptionByEndpoint(ctx, arg) - m.queryLatencies.WithLabelValues("DeleteNotificationPushSubscriptionByEndpoint").Observe(time.Since(start).Seconds()) - return r0 -} - -func (m queryMetricsStore) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { - start := time.Now() - r0 := m.s.DeleteNotificationPushSubscriptions(ctx, ids) - m.queryLatencies.WithLabelValues("DeleteNotificationPushSubscriptions").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteOAuth2ProviderAppByID(ctx, id) @@ -431,6 +431,20 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + start := time.Now() + r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteWebpushSubscriptionByUserIDAndEndpoint").Observe(time.Since(start).Seconds()) + return r0 +} + +func (m queryMetricsStore) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteWebpushSubscriptions(ctx, ids) + m.queryLatencies.WithLabelValues("DeleteWebpushSubscriptions").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { start := time.Now() r0 := m.s.DeleteWorkspaceAgentPortShare(ctx, arg) @@ -907,13 +921,6 @@ func (m queryMetricsStore) GetNotificationMessagesByStatus(ctx context.Context, return r0, r1 } -func (m queryMetricsStore) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { - start := time.Now() - r0, r1 := m.s.GetNotificationPushSubscriptionsByUserID(ctx, userID) - m.queryLatencies.WithLabelValues("GetNotificationPushSubscriptionsByUserID").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { start := time.Now() r0, r1 := m.s.GetNotificationReportGeneratorLogByTemplate(ctx, arg) @@ -935,13 +942,6 @@ func (m queryMetricsStore) GetNotificationTemplatesByKind(ctx context.Context, k return r0, r1 } -func (m queryMetricsStore) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { - start := time.Now() - r0, r1 := m.s.GetNotificationVAPIDKeys(ctx) - m.queryLatencies.WithLabelValues("GetNotificationVAPIDKeys").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) GetNotificationsSettings(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetNotificationsSettings(ctx) @@ -1537,6 +1537,13 @@ func (m queryMetricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ( return users, err } +func (m queryMetricsStore) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) { + start := time.Now() + r0, r1 := m.s.GetWebpushSubscriptionsByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("GetWebpushSubscriptionsByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) @@ -2006,13 +2013,6 @@ func (m queryMetricsStore) InsertMissingGroups(ctx context.Context, arg database return r0, r1 } -func (m queryMetricsStore) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { - start := time.Now() - r0, r1 := m.s.InsertNotificationPushSubscription(ctx, arg) - m.queryLatencies.WithLabelValues("InsertNotificationPushSubscription").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.InsertOAuth2ProviderApp(ctx, arg) @@ -2188,6 +2188,13 @@ func (m queryMetricsStore) InsertVolumeResourceMonitor(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) { + start := time.Now() + r0, r1 := m.s.InsertWebpushSubscription(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWebpushSubscription").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) { start := time.Now() workspace, err := m.s.InsertWorkspace(ctx, arg) @@ -2965,13 +2972,6 @@ func (m queryMetricsStore) UpsertNotificationReportGeneratorLog(ctx context.Cont return r0 } -func (m queryMetricsStore) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { - start := time.Now() - r0 := m.s.UpsertNotificationVAPIDKeys(ctx, arg) - m.queryLatencies.WithLabelValues("UpsertNotificationVAPIDKeys").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpsertNotificationsSettings(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertNotificationsSettings(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2528effd521c2..c5a5db20a9e90 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -290,20 +290,6 @@ func (mr *MockStoreMockRecorder) DeleteAPIKeysByUserID(ctx, userID any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteAPIKeysByUserID), ctx, userID) } -// DeleteAllNotificationPushSubscriptions mocks base method. -func (m *MockStore) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAllNotificationPushSubscriptions", ctx) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteAllNotificationPushSubscriptions indicates an expected call of DeleteAllNotificationPushSubscriptions. -func (mr *MockStoreMockRecorder) DeleteAllNotificationPushSubscriptions(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllNotificationPushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteAllNotificationPushSubscriptions), ctx) -} - // DeleteAllTailnetClientSubscriptions mocks base method. func (m *MockStore) DeleteAllTailnetClientSubscriptions(ctx context.Context, arg database.DeleteAllTailnetClientSubscriptionsParams) error { m.ctrl.T.Helper() @@ -332,6 +318,20 @@ func (mr *MockStoreMockRecorder) DeleteAllTailnetTunnels(ctx, arg any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllTailnetTunnels", reflect.TypeOf((*MockStore)(nil).DeleteAllTailnetTunnels), ctx, arg) } +// DeleteAllWebpushSubscriptions mocks base method. +func (m *MockStore) DeleteAllWebpushSubscriptions(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllWebpushSubscriptions", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllWebpushSubscriptions indicates an expected call of DeleteAllWebpushSubscriptions. +func (mr *MockStoreMockRecorder) DeleteAllWebpushSubscriptions(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllWebpushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteAllWebpushSubscriptions), ctx) +} + // DeleteApplicationConnectAPIKeysByUserID mocks base method. func (m *MockStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { m.ctrl.T.Helper() @@ -460,34 +460,6 @@ func (mr *MockStoreMockRecorder) DeleteLicense(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLicense", reflect.TypeOf((*MockStore)(nil).DeleteLicense), ctx, id) } -// DeleteNotificationPushSubscriptionByEndpoint mocks base method. -func (m *MockStore) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg database.DeleteNotificationPushSubscriptionByEndpointParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteNotificationPushSubscriptionByEndpoint", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteNotificationPushSubscriptionByEndpoint indicates an expected call of DeleteNotificationPushSubscriptionByEndpoint. -func (mr *MockStoreMockRecorder) DeleteNotificationPushSubscriptionByEndpoint(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationPushSubscriptionByEndpoint", reflect.TypeOf((*MockStore)(nil).DeleteNotificationPushSubscriptionByEndpoint), ctx, arg) -} - -// DeleteNotificationPushSubscriptions mocks base method. -func (m *MockStore) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteNotificationPushSubscriptions", ctx, ids) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteNotificationPushSubscriptions indicates an expected call of DeleteNotificationPushSubscriptions. -func (mr *MockStoreMockRecorder) DeleteNotificationPushSubscriptions(ctx, ids any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNotificationPushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteNotificationPushSubscriptions), ctx, ids) -} - // DeleteOAuth2ProviderAppByID mocks base method. func (m *MockStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -744,6 +716,34 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg) } +// DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method. +func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWebpushSubscriptionByUserIDAndEndpoint", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWebpushSubscriptionByUserIDAndEndpoint indicates an expected call of DeleteWebpushSubscriptionByUserIDAndEndpoint. +func (mr *MockStoreMockRecorder) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebpushSubscriptionByUserIDAndEndpoint", reflect.TypeOf((*MockStore)(nil).DeleteWebpushSubscriptionByUserIDAndEndpoint), ctx, arg) +} + +// DeleteWebpushSubscriptions mocks base method. +func (m *MockStore) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWebpushSubscriptions", ctx, ids) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWebpushSubscriptions indicates an expected call of DeleteWebpushSubscriptions. +func (mr *MockStoreMockRecorder) DeleteWebpushSubscriptions(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebpushSubscriptions", reflect.TypeOf((*MockStore)(nil).DeleteWebpushSubscriptions), ctx, ids) +} + // DeleteWorkspaceAgentPortShare mocks base method. func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { m.ctrl.T.Helper() @@ -1834,21 +1834,6 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), ctx, arg) } -// GetNotificationPushSubscriptionsByUserID mocks base method. -func (m *MockStore) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.NotificationPushSubscription, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotificationPushSubscriptionsByUserID", ctx, userID) - ret0, _ := ret[0].([]database.NotificationPushSubscription) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetNotificationPushSubscriptionsByUserID indicates an expected call of GetNotificationPushSubscriptionsByUserID. -func (mr *MockStoreMockRecorder) GetNotificationPushSubscriptionsByUserID(ctx, userID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationPushSubscriptionsByUserID", reflect.TypeOf((*MockStore)(nil).GetNotificationPushSubscriptionsByUserID), ctx, userID) -} - // GetNotificationReportGeneratorLogByTemplate mocks base method. func (m *MockStore) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (database.NotificationReportGeneratorLog, error) { m.ctrl.T.Helper() @@ -1894,21 +1879,6 @@ func (mr *MockStoreMockRecorder) GetNotificationTemplatesByKind(ctx, kind any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplatesByKind", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplatesByKind), ctx, kind) } -// GetNotificationVAPIDKeys mocks base method. -func (m *MockStore) GetNotificationVAPIDKeys(ctx context.Context) (database.GetNotificationVAPIDKeysRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotificationVAPIDKeys", ctx) - ret0, _ := ret[0].(database.GetNotificationVAPIDKeysRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetNotificationVAPIDKeys indicates an expected call of GetNotificationVAPIDKeys. -func (mr *MockStoreMockRecorder) GetNotificationVAPIDKeys(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetNotificationVAPIDKeys), ctx) -} - // GetNotificationsSettings mocks base method. func (m *MockStore) GetNotificationsSettings(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -3214,6 +3184,36 @@ func (mr *MockStoreMockRecorder) GetUsersByIDs(ctx, ids any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIDs", reflect.TypeOf((*MockStore)(nil).GetUsersByIDs), ctx, ids) } +// GetWebpushSubscriptionsByUserID mocks base method. +func (m *MockStore) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]database.WebpushSubscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWebpushSubscriptionsByUserID", ctx, userID) + ret0, _ := ret[0].([]database.WebpushSubscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWebpushSubscriptionsByUserID indicates an expected call of GetWebpushSubscriptionsByUserID. +func (mr *MockStoreMockRecorder) GetWebpushSubscriptionsByUserID(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushSubscriptionsByUserID", reflect.TypeOf((*MockStore)(nil).GetWebpushSubscriptionsByUserID), ctx, userID) +} + +// GetWebpushVAPIDKeys mocks base method. +func (m *MockStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWebpushVAPIDKeys", ctx) + ret0, _ := ret[0].(database.GetWebpushVAPIDKeysRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWebpushVAPIDKeys indicates an expected call of GetWebpushVAPIDKeys. +func (mr *MockStoreMockRecorder) GetWebpushVAPIDKeys(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetWebpushVAPIDKeys), ctx) +} + // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() @@ -4229,21 +4229,6 @@ func (mr *MockStoreMockRecorder) InsertMissingGroups(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), ctx, arg) } -// InsertNotificationPushSubscription mocks base method. -func (m *MockStore) InsertNotificationPushSubscription(ctx context.Context, arg database.InsertNotificationPushSubscriptionParams) (database.NotificationPushSubscription, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertNotificationPushSubscription", ctx, arg) - ret0, _ := ret[0].(database.NotificationPushSubscription) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertNotificationPushSubscription indicates an expected call of InsertNotificationPushSubscription. -func (mr *MockStoreMockRecorder) InsertNotificationPushSubscription(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertNotificationPushSubscription", reflect.TypeOf((*MockStore)(nil).InsertNotificationPushSubscription), ctx, arg) -} - // InsertOAuth2ProviderApp mocks base method. func (m *MockStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -4614,6 +4599,21 @@ func (mr *MockStoreMockRecorder) InsertVolumeResourceMonitor(ctx, arg any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertVolumeResourceMonitor", reflect.TypeOf((*MockStore)(nil).InsertVolumeResourceMonitor), ctx, arg) } +// InsertWebpushSubscription mocks base method. +func (m *MockStore) InsertWebpushSubscription(ctx context.Context, arg database.InsertWebpushSubscriptionParams) (database.WebpushSubscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWebpushSubscription", ctx, arg) + ret0, _ := ret[0].(database.WebpushSubscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertWebpushSubscription indicates an expected call of InsertWebpushSubscription. +func (mr *MockStoreMockRecorder) InsertWebpushSubscription(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWebpushSubscription", reflect.TypeOf((*MockStore)(nil).InsertWebpushSubscription), ctx, arg) +} + // InsertWorkspace mocks base method. func (m *MockStore) InsertWorkspace(ctx context.Context, arg database.InsertWorkspaceParams) (database.WorkspaceTable, error) { m.ctrl.T.Helper() @@ -6246,20 +6246,6 @@ func (mr *MockStoreMockRecorder) UpsertNotificationReportGeneratorLog(ctx, arg a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationReportGeneratorLog", reflect.TypeOf((*MockStore)(nil).UpsertNotificationReportGeneratorLog), ctx, arg) } -// UpsertNotificationVAPIDKeys mocks base method. -func (m *MockStore) UpsertNotificationVAPIDKeys(ctx context.Context, arg database.UpsertNotificationVAPIDKeysParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertNotificationVAPIDKeys", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertNotificationVAPIDKeys indicates an expected call of UpsertNotificationVAPIDKeys. -func (mr *MockStoreMockRecorder) UpsertNotificationVAPIDKeys(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationVAPIDKeys", reflect.TypeOf((*MockStore)(nil).UpsertNotificationVAPIDKeys), ctx, arg) -} - // UpsertNotificationsSettings mocks base method. func (m *MockStore) UpsertNotificationsSettings(ctx context.Context, value string) error { m.ctrl.T.Helper() @@ -6448,6 +6434,20 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx) } +// UpsertWebpushVAPIDKeys mocks base method. +func (m *MockStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertWebpushVAPIDKeys", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertWebpushVAPIDKeys indicates an expected call of UpsertWebpushVAPIDKeys. +func (mr *MockStoreMockRecorder) UpsertWebpushVAPIDKeys(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).UpsertWebpushVAPIDKeys), ctx, arg) +} + // UpsertWorkspaceAgentPortShare mocks base method. func (m *MockStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 296db2114e852..b7908a8880107 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -996,15 +996,6 @@ CREATE TABLE notification_preferences ( updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); -CREATE TABLE notification_push_subscriptions ( - id uuid DEFAULT gen_random_uuid() NOT NULL, - user_id uuid NOT NULL, - created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - endpoint text NOT NULL, - endpoint_p256dh_key text NOT NULL, - endpoint_auth_key text NOT NULL -); - CREATE TABLE notification_report_generator_logs ( notification_template_id uuid NOT NULL, last_generated_at timestamp with time zone NOT NULL @@ -1623,6 +1614,15 @@ CREATE TABLE user_status_changes ( COMMENT ON TABLE user_status_changes IS 'Tracks the history of user status changes'; +CREATE TABLE webpush_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + endpoint text NOT NULL, + endpoint_p256dh_key text NOT NULL, + endpoint_auth_key text NOT NULL +); + CREATE TABLE workspace_agent_devcontainers ( id uuid NOT NULL, workspace_agent_id uuid NOT NULL, @@ -2182,9 +2182,6 @@ ALTER TABLE ONLY notification_messages ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); -ALTER TABLE ONLY notification_push_subscriptions - ADD CONSTRAINT notification_push_subscriptions_pkey PRIMARY KEY (id); - ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); @@ -2317,6 +2314,9 @@ ALTER TABLE ONLY user_status_changes ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY webpush_subscriptions + ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id); @@ -2649,9 +2649,6 @@ ALTER TABLE ONLY notification_preferences ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE ONLY notification_push_subscriptions - ADD CONSTRAINT notification_push_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; @@ -2760,6 +2757,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); +ALTER TABLE ONLY webpush_subscriptions + ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 69645c335a417..7dab8519a897c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -22,7 +22,6 @@ const ( ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyNotificationPushSubscriptionsUserID ForeignKeyConstraint = "notification_push_subscriptions_user_id_fkey" // ALTER TABLE ONLY notification_push_subscriptions ADD CONSTRAINT notification_push_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; @@ -59,6 +58,7 @@ const ( ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000311_push_notifications.down.sql b/coderd/database/migrations/000311_push_notifications.down.sql deleted file mode 100644 index ae3c8c72a9b0b..0000000000000 --- a/coderd/database/migrations/000311_push_notifications.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS notification_push_subscriptions; - diff --git a/coderd/database/migrations/000311_webpush_subscriptions.down.sql b/coderd/database/migrations/000311_webpush_subscriptions.down.sql new file mode 100644 index 0000000000000..48cf4168328af --- /dev/null +++ b/coderd/database/migrations/000311_webpush_subscriptions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS webpush_subscriptions; + diff --git a/coderd/database/migrations/000311_push_notifications.up.sql b/coderd/database/migrations/000311_webpush_subscriptions.up.sql similarity index 80% rename from coderd/database/migrations/000311_push_notifications.up.sql rename to coderd/database/migrations/000311_webpush_subscriptions.up.sql index 61303663dfa55..8319bbb2f5743 100644 --- a/coderd/database/migrations/000311_push_notifications.up.sql +++ b/coderd/database/migrations/000311_webpush_subscriptions.up.sql @@ -1,6 +1,6 @@ --- notification_push_subscriptions is a table that stores push notification +-- webpush_subscriptions is a table that stores push notification -- subscriptions for users. These are acquired via the Push API in the browser. -CREATE TABLE IF NOT EXISTS notification_push_subscriptions ( +CREATE TABLE IF NOT EXISTS webpush_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql b/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql deleted file mode 100644 index a5a3bcc862a6d..0000000000000 --- a/coderd/database/migrations/testdata/fixtures/000311_push_notifications.up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- VAPID keys lited from coderd/notifications_test.go. -INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) VALUES (gen_random_uuid(), (SELECT id FROM users LIMIT 1), NOW(), 'https://example.com', 'BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=', 'zqbxT6JKstKSY9JKibZLSQ=='); diff --git a/coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql b/coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql new file mode 100644 index 0000000000000..4f3e3b0685928 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql @@ -0,0 +1,2 @@ +-- VAPID keys lited from coderd/notifications_test.go. +INSERT INTO webpush_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) VALUES (gen_random_uuid(), (SELECT id FROM users LIMIT 1), NOW(), 'https://example.com', 'BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=', 'zqbxT6JKstKSY9JKibZLSQ=='); diff --git a/coderd/database/models.go b/coderd/database/models.go index 10d075c8d127e..634cb6b59a41a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2680,15 +2680,6 @@ type NotificationPreference struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -type NotificationPushSubscription struct { - ID uuid.UUID `db:"id" json:"id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Endpoint string `db:"endpoint" json:"endpoint"` - EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"` - EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` -} - // Log of generated reports for users. type NotificationReportGeneratorLog struct { NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"` @@ -3249,6 +3240,15 @@ type VisibleUser struct { AvatarURL string `db:"avatar_url" json:"avatar_url"` } +type WebpushSubscription struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Endpoint string `db:"endpoint" json:"endpoint"` + EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"` + EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` +} + // Joins in the display name information such as username, avatar, and organization name. type Workspace struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5f85a0cb45927..892582c1201e5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -67,13 +67,13 @@ type sqlcQuerier interface { CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - // Deletes all existing notification push subscriptions. + DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error + DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error + // Deletes all existing webpush subscriptions. // This should be called when the VAPID keypair is regenerated, as the old // keypair will no longer be valid and all existing subscriptions will need to // be recreated. - DeleteAllNotificationPushSubscriptions(ctx context.Context) error - DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error - DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error + DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) @@ -83,8 +83,6 @@ type sqlcQuerier interface { DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error DeleteLicense(ctx context.Context, id int32) (int32, error) - DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg DeleteNotificationPushSubscriptionByEndpointParams) error - DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error @@ -111,6 +109,8 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error + DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error // Disable foreign keys and triggers for all tables. @@ -205,12 +205,10 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) - GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]NotificationPushSubscription, error) // Fetch the notification report generator log indicating recent activity. GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (NotificationReportGeneratorLog, error) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) - GetNotificationVAPIDKeys(ctx context.Context) (GetNotificationVAPIDKeysRow, error) GetNotificationsSettings(ctx context.Context) (string, error) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) @@ -349,6 +347,8 @@ type sqlcQuerier interface { // to look up references to actions. eg. a user could build a workspace // for another user, then be deleted... we still want them to appear! GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) + GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) + GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) @@ -434,7 +434,6 @@ type sqlcQuerier interface { // values for avatar, display name, and quota allowance (all zero values). // If the name conflicts, do nothing. InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) - InsertNotificationPushSubscription(ctx context.Context, arg InsertNotificationPushSubscriptionParams) (NotificationPushSubscription, error) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) InsertOAuth2ProviderAppCode(ctx context.Context, arg InsertOAuth2ProviderAppCodeParams) (OAuth2ProviderAppCode, error) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) @@ -463,6 +462,7 @@ type sqlcQuerier interface { InsertUserGroupsByName(ctx context.Context, arg InsertUserGroupsByNameParams) error InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error) + InsertWebpushSubscription(ctx context.Context, arg InsertWebpushSubscriptionParams) (WebpushSubscription, error) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (WorkspaceTable, error) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg InsertWorkspaceAgentDevcontainersParams) ([]WorkspaceAgentDevcontainer, error) @@ -590,7 +590,6 @@ type sqlcQuerier interface { UpsertLogoURL(ctx context.Context, value string) error // Insert or update notification report generator logs with recent activity. UpsertNotificationReportGeneratorLog(ctx context.Context, arg UpsertNotificationReportGeneratorLogParams) error - UpsertNotificationVAPIDKeys(ctx context.Context, arg UpsertNotificationVAPIDKeysParams) error UpsertNotificationsSettings(ctx context.Context, value string) error UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error UpsertOAuthSigningKey(ctx context.Context, value string) error @@ -608,6 +607,7 @@ type sqlcQuerier interface { // used to store the data, and the minutes are summed for each user and template // combination. The result is stored in the template_usage_stats table. UpsertTemplateUsageStats(ctx context.Context) error + UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) // // The returned boolean, new_or_stale, can be used to deduce if a new session diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dc7d13999fb88..221c9f2c51df6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3988,56 +3988,56 @@ func (q *sqlQuerier) BulkMarkNotificationMessagesSent(ctx context.Context, arg B return result.RowsAffected() } -const deleteAllNotificationPushSubscriptions = `-- name: DeleteAllNotificationPushSubscriptions :exec -TRUNCATE TABLE notification_push_subscriptions +const deleteAllWebpushSubscriptions = `-- name: DeleteAllWebpushSubscriptions :exec +TRUNCATE TABLE webpush_subscriptions ` -// Deletes all existing notification push subscriptions. +// Deletes all existing webpush subscriptions. // This should be called when the VAPID keypair is regenerated, as the old // keypair will no longer be valid and all existing subscriptions will need to // be recreated. -func (q *sqlQuerier) DeleteAllNotificationPushSubscriptions(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteAllNotificationPushSubscriptions) +func (q *sqlQuerier) DeleteAllWebpushSubscriptions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllWebpushSubscriptions) return err } -const deleteNotificationPushSubscriptionByEndpoint = `-- name: DeleteNotificationPushSubscriptionByEndpoint :exec -DELETE FROM notification_push_subscriptions +const deleteOldNotificationMessages = `-- name: DeleteOldNotificationMessages :exec +DELETE +FROM notification_messages +WHERE id IN + (SELECT id + FROM notification_messages AS nested + WHERE nested.updated_at < NOW() - INTERVAL '7 days') +` + +// Delete all notification messages which have not been updated for over a week. +func (q *sqlQuerier) DeleteOldNotificationMessages(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteOldNotificationMessages) + return err +} + +const deleteWebpushSubscriptionByUserIDAndEndpoint = `-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec +DELETE FROM webpush_subscriptions WHERE user_id = $1 AND endpoint = $2 ` -type DeleteNotificationPushSubscriptionByEndpointParams struct { +type DeleteWebpushSubscriptionByUserIDAndEndpointParams struct { UserID uuid.UUID `db:"user_id" json:"user_id"` Endpoint string `db:"endpoint" json:"endpoint"` } -func (q *sqlQuerier) DeleteNotificationPushSubscriptionByEndpoint(ctx context.Context, arg DeleteNotificationPushSubscriptionByEndpointParams) error { - _, err := q.db.ExecContext(ctx, deleteNotificationPushSubscriptionByEndpoint, arg.UserID, arg.Endpoint) +func (q *sqlQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + _, err := q.db.ExecContext(ctx, deleteWebpushSubscriptionByUserIDAndEndpoint, arg.UserID, arg.Endpoint) return err } -const deleteNotificationPushSubscriptions = `-- name: DeleteNotificationPushSubscriptions :exec -DELETE FROM notification_push_subscriptions +const deleteWebpushSubscriptions = `-- name: DeleteWebpushSubscriptions :exec +DELETE FROM webpush_subscriptions WHERE id = ANY($1::uuid[]) ` -func (q *sqlQuerier) DeleteNotificationPushSubscriptions(ctx context.Context, ids []uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteNotificationPushSubscriptions, pq.Array(ids)) - return err -} - -const deleteOldNotificationMessages = `-- name: DeleteOldNotificationMessages :exec -DELETE -FROM notification_messages -WHERE id IN - (SELECT id - FROM notification_messages AS nested - WHERE nested.updated_at < NOW() - INTERVAL '7 days') -` - -// Delete all notification messages which have not been updated for over a week. -func (q *sqlQuerier) DeleteOldNotificationMessages(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteOldNotificationMessages) +func (q *sqlQuerier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteWebpushSubscriptions, pq.Array(ids)) return err } @@ -4178,42 +4178,6 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge return items, nil } -const getNotificationPushSubscriptionsByUserID = `-- name: GetNotificationPushSubscriptionsByUserID :many -SELECT id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key -FROM notification_push_subscriptions -WHERE user_id = $1::uuid -` - -func (q *sqlQuerier) GetNotificationPushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]NotificationPushSubscription, error) { - rows, err := q.db.QueryContext(ctx, getNotificationPushSubscriptionsByUserID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []NotificationPushSubscription - for rows.Next() { - var i NotificationPushSubscription - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.CreatedAt, - &i.Endpoint, - &i.EndpointP256dhKey, - &i.EndpointAuthKey, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getNotificationReportGeneratorLogByTemplate = `-- name: GetNotificationReportGeneratorLogByTemplate :one SELECT notification_template_id, last_generated_at @@ -4329,14 +4293,49 @@ func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID return items, nil } -const insertNotificationPushSubscription = `-- name: InsertNotificationPushSubscription :one -INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) -VALUES ($1, $2, $3, $4, $5, $6) +const getWebpushSubscriptionsByUserID = `-- name: GetWebpushSubscriptionsByUserID :many +SELECT id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key +FROM webpush_subscriptions +WHERE user_id = $1::uuid +` + +func (q *sqlQuerier) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) { + rows, err := q.db.QueryContext(ctx, getWebpushSubscriptionsByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WebpushSubscription + for rows.Next() { + var i WebpushSubscription + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.Endpoint, + &i.EndpointP256dhKey, + &i.EndpointAuthKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertWebpushSubscription = `-- name: InsertWebpushSubscription :one +INSERT INTO webpush_subscriptions (user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) +VALUES ($1, $2, $3, $4, $5) RETURNING id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key ` -type InsertNotificationPushSubscriptionParams struct { - ID uuid.UUID `db:"id" json:"id"` +type InsertWebpushSubscriptionParams struct { UserID uuid.UUID `db:"user_id" json:"user_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` Endpoint string `db:"endpoint" json:"endpoint"` @@ -4344,16 +4343,15 @@ type InsertNotificationPushSubscriptionParams struct { EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` } -func (q *sqlQuerier) InsertNotificationPushSubscription(ctx context.Context, arg InsertNotificationPushSubscriptionParams) (NotificationPushSubscription, error) { - row := q.db.QueryRowContext(ctx, insertNotificationPushSubscription, - arg.ID, +func (q *sqlQuerier) InsertWebpushSubscription(ctx context.Context, arg InsertWebpushSubscriptionParams) (WebpushSubscription, error) { + row := q.db.QueryRowContext(ctx, insertWebpushSubscription, arg.UserID, arg.CreatedAt, arg.Endpoint, arg.EndpointP256dhKey, arg.EndpointAuthKey, ) - var i NotificationPushSubscription + var i WebpushSubscription err := row.Scan( &i.ID, &i.UserID, @@ -8620,24 +8618,6 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) { return value, err } -const getNotificationVAPIDKeys = `-- name: GetNotificationVAPIDKeys :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_public_key'), '') :: text AS vapid_public_key, - COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_private_key'), '') :: text AS vapid_private_key -` - -type GetNotificationVAPIDKeysRow struct { - VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` - VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` -} - -func (q *sqlQuerier) GetNotificationVAPIDKeys(ctx context.Context) (GetNotificationVAPIDKeysRow, error) { - row := q.db.QueryRowContext(ctx, getNotificationVAPIDKeys) - var i GetNotificationVAPIDKeysRow - err := row.Scan(&i.VapidPublicKey, &i.VapidPrivateKey) - return i, err -} - const getNotificationsSettings = `-- name: GetNotificationsSettings :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings @@ -8689,6 +8669,24 @@ func (q *sqlQuerier) GetRuntimeConfig(ctx context.Context, key string) (string, return value, err } +const getWebpushVAPIDKeys = `-- name: GetWebpushVAPIDKeys :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key, + COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key +` + +type GetWebpushVAPIDKeysRow struct { + VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` + VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` +} + +func (q *sqlQuerier) GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) { + row := q.db.QueryRowContext(ctx, getWebpushVAPIDKeys) + var i GetWebpushVAPIDKeysRow + err := row.Scan(&i.VapidPublicKey, &i.VapidPrivateKey) + return i, err +} + const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1) ` @@ -8800,25 +8798,6 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error { return err } -const upsertNotificationVAPIDKeys = `-- name: UpsertNotificationVAPIDKeys :exec -INSERT INTO site_configs (key, value) -VALUES - ('notification_vapid_public_key', $1 :: text), - ('notification_vapid_private_key', $2 :: text) -ON CONFLICT (key) -DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key -` - -type UpsertNotificationVAPIDKeysParams struct { - VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` - VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` -} - -func (q *sqlQuerier) UpsertNotificationVAPIDKeys(ctx context.Context, arg UpsertNotificationVAPIDKeysParams) error { - _, err := q.db.ExecContext(ctx, upsertNotificationVAPIDKeys, arg.VapidPublicKey, arg.VapidPrivateKey) - return err -} - const upsertNotificationsSettings = `-- name: UpsertNotificationsSettings :exec INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings' @@ -8876,6 +8855,25 @@ func (q *sqlQuerier) UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeC return err } +const upsertWebpushVAPIDKeys = `-- name: UpsertWebpushVAPIDKeys :exec +INSERT INTO site_configs (key, value) +VALUES + ('webpush_vapid_public_key', $1 :: text), + ('webpush_vapid_private_key', $2 :: text) +ON CONFLICT (key) +DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key +` + +type UpsertWebpushVAPIDKeysParams struct { + VapidPublicKey string `db:"vapid_public_key" json:"vapid_public_key"` + VapidPrivateKey string `db:"vapid_private_key" json:"vapid_private_key"` +} + +func (q *sqlQuerier) UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error { + _, err := q.db.ExecContext(ctx, upsertWebpushVAPIDKeys, arg.VapidPublicKey, arg.VapidPrivateKey) + return err +} + const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec DELETE FROM tailnet_coordinators diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index 7dbf9778d5fa2..bf65855925339 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -190,27 +190,27 @@ INSERT INTO notification_report_generator_logs (notification_template_id, last_g ON CONFLICT (notification_template_id) DO UPDATE set last_generated_at = EXCLUDED.last_generated_at WHERE notification_report_generator_logs.notification_template_id = EXCLUDED.notification_template_id; --- name: GetNotificationPushSubscriptionsByUserID :many +-- name: GetWebpushSubscriptionsByUserID :many SELECT * -FROM notification_push_subscriptions +FROM webpush_subscriptions WHERE user_id = @user_id::uuid; --- name: InsertNotificationPushSubscription :one -INSERT INTO notification_push_subscriptions (id, user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) -VALUES ($1, $2, $3, $4, $5, $6) +-- name: InsertWebpushSubscription :one +INSERT INTO webpush_subscriptions (user_id, created_at, endpoint, endpoint_p256dh_key, endpoint_auth_key) +VALUES ($1, $2, $3, $4, $5) RETURNING *; --- name: DeleteNotificationPushSubscriptions :exec -DELETE FROM notification_push_subscriptions +-- name: DeleteWebpushSubscriptions :exec +DELETE FROM webpush_subscriptions WHERE id = ANY(@ids::uuid[]); --- name: DeleteNotificationPushSubscriptionByEndpoint :exec -DELETE FROM notification_push_subscriptions +-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec +DELETE FROM webpush_subscriptions WHERE user_id = @user_id AND endpoint = @endpoint; --- name: DeleteAllNotificationPushSubscriptions :exec --- Deletes all existing notification push subscriptions. +-- name: DeleteAllWebpushSubscriptions :exec +-- Deletes all existing webpush subscriptions. -- This should be called when the VAPID keypair is regenerated, as the old -- keypair will no longer be valid and all existing subscriptions will need to -- be recreated. -TRUNCATE TABLE notification_push_subscriptions; +TRUNCATE TABLE webpush_subscriptions; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index ba0ce7b86e2a6..7ea0e7b001807 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -132,15 +132,15 @@ SET value = CASE END WHERE site_configs.key = 'oauth2_github_default_eligible'; --- name: UpsertNotificationVAPIDKeys :exec +-- name: UpsertWebpushVAPIDKeys :exec INSERT INTO site_configs (key, value) VALUES - ('notification_vapid_public_key', @vapid_public_key :: text), - ('notification_vapid_private_key', @vapid_private_key :: text) + ('webpush_vapid_public_key', @vapid_public_key :: text), + ('webpush_vapid_private_key', @vapid_private_key :: text) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key; --- name: GetNotificationVAPIDKeys :one +-- name: GetWebpushVAPIDKeys :one SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_public_key'), '') :: text AS vapid_public_key, - COALESCE((SELECT value FROM site_configs WHERE key = 'notification_vapid_private_key'), '') :: text AS vapid_private_key; + COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key, + COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 90e3fc79c333b..9318e1af1678b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -27,7 +27,6 @@ const ( UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); - UniqueNotificationPushSubscriptionsPkey UniqueConstraint = "notification_push_subscriptions_pkey" // ALTER TABLE ONLY notification_push_subscriptions ADD CONSTRAINT notification_push_subscriptions_pkey PRIMARY KEY (id); UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); @@ -72,6 +71,7 @@ const ( UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); + UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); UniqueWorkspaceAgentDevcontainersPkey UniqueConstraint = "workspace_agent_devcontainers_pkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id); diff --git a/coderd/notifications.go b/coderd/notifications.go index 1c26104f989bb..42b734549239e 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -332,20 +332,20 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R // @Security CoderSessionToken // @Accept json // @Tags Notifications -// @Param request body codersdk.PushNotificationSubscription true "Push notification subscription" +// @Param request body codersdk.WebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/notifications/push/subscription [post] // @Success 204 -func (api *API) postUserPushNotificationSubscription(rw http.ResponseWriter, r *http.Request) { +func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) - var req codersdk.PushNotificationSubscription + var req codersdk.WebpushSubscription if !httpapi.Read(ctx, rw, r, &req) { return } - notificationJSON, err := json.Marshal(codersdk.PushNotification{ + notificationJSON, err := json.Marshal(codersdk.WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", }) @@ -386,8 +386,7 @@ func (api *API) postUserPushNotificationSubscription(rw http.ResponseWriter, r * return } - _, err = api.Database.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: uuid.New(), + _, err = api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ CreatedAt: dbtime.Now(), UserID: user.ID, Endpoint: req.Endpoint, @@ -410,20 +409,20 @@ func (api *API) postUserPushNotificationSubscription(rw http.ResponseWriter, r * // @Security CoderSessionToken // @Accept json // @Tags Notifications -// @Param request body codersdk.DeletePushNotificationSubscription true "Push notification subscription" +// @Param request body codersdk.DeleteWebpushSubscription true "Push notification subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/notifications/push/subscription [delete] // @Success 204 -func (api *API) deleteUserPushNotificationSubscription(rw http.ResponseWriter, r *http.Request) { +func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) - var req codersdk.DeletePushNotificationSubscription + var req codersdk.DeleteWebpushSubscription if !httpapi.Read(ctx, rw, r, &req) { return } - err := api.Database.DeleteNotificationPushSubscriptionByEndpoint(ctx, database.DeleteNotificationPushSubscriptionByEndpointParams{ + err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ UserID: user.ID, Endpoint: req.Endpoint, }) @@ -449,7 +448,7 @@ func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Req ctx := r.Context() user := httpmw.UserParam(r) - if err := api.PushNotifier.Dispatch(ctx, user.ID, codersdk.PushNotification{ + if err := api.PushNotifier.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", }); err != nil { diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index 414ceb0873481..581e040e5b73c 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -24,7 +24,7 @@ import ( // NotificationDispatcher is an interface that can be used to dispatch // push notifications. type NotificationDispatcher interface { - Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.PushNotification) error + Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error PublicKey() string PrivateKey() string } @@ -38,7 +38,7 @@ type NotificationDispatcher interface { // // See: https://github.com/coder/internal/issues/528 func New(ctx context.Context, log *slog.Logger, db database.Store) (NotificationDispatcher, error) { - keys, err := db.GetNotificationVAPIDKeys(ctx) + keys, err := db.GetWebpushVAPIDKeys(ctx) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return nil, xerrors.Errorf("get notification vapid keys: %w", err) @@ -73,8 +73,8 @@ type Notifier struct { VAPIDPrivateKey string } -func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.PushNotification) error { - subscriptions, err := n.store.GetNotificationPushSubscriptionsByUserID(ctx, userID) +func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error { + subscriptions, err := n.store.GetWebpushSubscriptionsByUserID(ctx, userID) if err != nil { return xerrors.Errorf("get notification push subscriptions by user ID: %w", err) } @@ -136,7 +136,7 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification if len(cleanupSubscriptions) > 0 { // nolint:gocritic // These are known to be invalid subscriptions. - err = n.store.DeleteNotificationPushSubscriptions(dbauthz.AsNotifier(ctx), cleanupSubscriptions) + err = n.store.DeleteWebpushSubscriptions(dbauthz.AsNotifier(ctx), cleanupSubscriptions) if err != nil { n.log.Error(ctx, "failed to delete stale push subscriptions", slog.Error(err)) } @@ -160,7 +160,7 @@ type NoopNotifier struct { Msg string } -func (n *NoopNotifier) Dispatch(context.Context, uuid.UUID, codersdk.PushNotification) error { +func (n *NoopNotifier) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMessage) error { return xerrors.New(n.Msg) } @@ -181,10 +181,10 @@ func RegenerateVAPIDKeys(ctx context.Context, db database.Store) (newPrivateKey } if txErr := db.InTx(func(tx database.Store) error { - if err := tx.DeleteAllNotificationPushSubscriptions(ctx); err != nil { - return xerrors.Errorf("delete all notification push subscriptions: %w", err) + if err := tx.DeleteAllWebpushSubscriptions(ctx); err != nil { + return xerrors.Errorf("delete all webpush subscriptions: %w", err) } - if err := tx.UpsertNotificationVAPIDKeys(ctx, database.UpsertNotificationVAPIDKeysParams{ + if err := tx.UpsertWebpushVAPIDKeys(ctx, database.UpsertWebpushVAPIDKeysParams{ VapidPrivateKey: newPrivateKey, VapidPublicKey: newPublicKey, }); err != nil { diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go index f30605ba774ab..9318d3fb99845 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/notifications/push/push_test.go @@ -36,8 +36,7 @@ func TestPush(t *testing.T) { w.WriteHeader(http.StatusOK) }) user := dbgen.User(t, store, database.User{}) - sub, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: uuid.New(), + sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, @@ -46,10 +45,10 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Title", Body: "Test Body", - Actions: []codersdk.PushNotificationAction{ + Actions: []codersdk.WebpushMessageAction{ {Label: "View", URL: "https://coder.com/view"}, }, Icon: "workspace", @@ -58,7 +57,7 @@ func TestPush(t *testing.T) { err = manager.Dispatch(ctx, user.ID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 1, "One subscription should be returned") assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") @@ -71,9 +70,7 @@ func TestPush(t *testing.T) { w.WriteHeader(http.StatusGone) }) user := dbgen.User(t, store, database.User{}) - subID := uuid.New() - _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: subID, + _, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, @@ -82,7 +79,7 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Title", Body: "Test Body", } @@ -90,7 +87,7 @@ func TestPush(t *testing.T) { err = manager.Dispatch(ctx, user.ID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 0, "No subscriptions should be returned") }) @@ -104,8 +101,7 @@ func TestPush(t *testing.T) { }) user := dbgen.User(t, store, database.User{}) - sub, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: uuid.New(), + sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ UserID: user.ID, Endpoint: serverURL, EndpointAuthKey: validEndpointAuthKey, @@ -114,7 +110,7 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Title", Body: "Test Body", } @@ -123,7 +119,7 @@ func TestPush(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Invalid request") - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, user.ID) + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID) require.NoError(t, err) assert.Len(t, subscriptions, 1, "One subscription should be returned") assert.Equal(t, subscriptions[0].ID, sub.ID, "The subscription should not be deleted") @@ -148,11 +144,8 @@ func TestPush(t *testing.T) { // Setup subscriptions pointing to our test servers user := dbgen.User(t, store, database.User{}) - sub1ID := uuid.New() - sub2ID := uuid.New() - _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: sub1ID, + sub1, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ UserID: user.ID, Endpoint: serverOKURL, EndpointAuthKey: validEndpointAuthKey, @@ -161,8 +154,7 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - _, err = store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: sub2ID, + _, err = store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ UserID: user.ID, Endpoint: serverGoneURL, EndpointAuthKey: validEndpointAuthKey, @@ -171,10 +163,10 @@ func TestPush(t *testing.T) { }) require.NoError(t, err) - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Title", Body: "Test Body", - Actions: []codersdk.PushNotificationAction{ + Actions: []codersdk.WebpushMessageAction{ {Label: "View", URL: "https://coder.com/view"}, }, } @@ -184,9 +176,12 @@ func TestPush(t *testing.T) { assert.True(t, okEndpointCalled, "The valid endpoint should be called") assert.True(t, goneEndpointCalled, "The expired endpoint should be called") - // assert.Len(t, store.deletedIDs, 1, "One subscription should be deleted") - // assert.Contains(t, store.deletedIDs, sub2ID, "The expired subscription should be deleted") - // assert.NotContains(t, store.deletedIDs, sub1ID, "The valid subscription should not be deleted") + // Assert that sub1 was not deleted. + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, user.ID) + require.NoError(t, err) + if assert.Len(t, subscriptions, 1, "One subscription should be returned") { + assert.Equal(t, subscriptions[0].ID, sub1.ID, "The valid subscription should not be deleted") + } }) t.Run("NotificationPayload", func(t *testing.T) { @@ -201,8 +196,7 @@ func TestPush(t *testing.T) { user := dbgen.User(t, store, database.User{}) - _, err := store.InsertNotificationPushSubscription(ctx, database.InsertNotificationPushSubscriptionParams{ - ID: uuid.New(), + _, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ CreatedAt: dbtime.Now(), UserID: user.ID, Endpoint: serverURL, @@ -211,10 +205,10 @@ func TestPush(t *testing.T) { }) require.NoError(t, err, "Failed to insert push subscription") - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Notification", Body: "This is a test notification body", - Actions: []codersdk.PushNotificationAction{ + Actions: []codersdk.WebpushMessageAction{ {Label: "View Workspace", URL: "https://coder.com/workspace/123"}, {Label: "Cancel", URL: "https://coder.com/cancel"}, }, @@ -234,7 +228,7 @@ func TestPush(t *testing.T) { }) userID := uuid.New() - notification := codersdk.PushNotification{ + notification := codersdk.WebpushMessage{ Title: "Test Title", Body: "Test Body", } @@ -242,7 +236,7 @@ func TestPush(t *testing.T) { err := manager.Dispatch(ctx, userID, notification) require.NoError(t, err) - subscriptions, err := store.GetNotificationPushSubscriptionsByUserID(ctx, userID) + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, userID) require.NoError(t, err) assert.Empty(t, subscriptions, "No subscriptions should be returned") }) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index a8a09a1441010..59a8fc60faf04 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -404,21 +404,21 @@ func TestPushNotificationSubscription(t *testing.T) { })) defer server.Close() - err := memberClient.CreateNotificationPushSubscription(ctx, "me", codersdk.PushNotificationSubscription{ + err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{ Endpoint: server.URL, AuthKey: validEndpointAuthKey, P256DHKey: validEndpointP256dhKey, }) - require.NoError(t, err, "create notification push subscription") + require.NoError(t, err, "create webpush subscription") require.True(t, <-handlerCalled, "handler should have been called") - err = memberClient.TestPushNotification(ctx) + err = memberClient.PostTestWebpushMessage(ctx) require.NoError(t, err, "test push notification") require.True(t, <-handlerCalled, "handler should have been called again") - err = memberClient.DeleteNotificationPushSubscription(ctx, "me", codersdk.DeletePushNotificationSubscription{ + err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ Endpoint: server.URL, }) - require.NoError(t, err, "delete notification push subscription") + require.NoError(t, err, "delete webpush subscription") }) } diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index c420281c375f9..9d515b22749dc 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -155,13 +155,13 @@ var ( Type: "notification_preference", } - // ResourceNotificationPushSubscription + // ResourceWebpushSubscription // Valid Actions - // - "ActionCreate" :: create notification push subscriptions - // - "ActionDelete" :: delete notification push subscriptions - // - "ActionRead" :: read notification push subscriptions - ResourceNotificationPushSubscription = Object{ - Type: "notification_push_subscription", + // - "ActionCreate" :: create webpush subscriptions + // - "ActionDelete" :: delete webpush subscriptions + // - "ActionRead" :: read webpush subscriptions + ResourceWebpushSubscription = Object{ + Type: "webpush_subscription", } // ResourceNotificationTemplate @@ -363,7 +363,7 @@ func AllResources() []Objecter { ResourceLicense, ResourceNotificationMessage, ResourceNotificationPreference, - ResourceNotificationPushSubscription, + ResourceWebpushSubscription, ResourceNotificationTemplate, ResourceOauth2App, ResourceOauth2AppCodeToken, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index ad65efaa62c47..801bbfebf30a5 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -280,11 +280,11 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: actDef("update notification preferences"), }, }, - "notification_push_subscription": { + "webpush_subscription": { Actions: map[Action]ActionDefinition{ - ActionCreate: actDef("create notification push subscriptions"), - ActionRead: actDef("read notification push subscriptions"), - ActionDelete: actDef("delete notification push subscriptions"), + ActionCreate: actDef("create webpush subscriptions"), + ActionRead: actDef("read webpush subscriptions"), + ActionDelete: actDef("delete webpush subscriptions"), }, }, "inbox_notification": { diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 340373da72d00..1080903637ac5 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -713,11 +713,11 @@ func TestRolePermissions(t *testing.T) { }, }, }, - // All users can create, read, and delete their own push notification subscriptions. + // All users can create, read, and delete their own webpush notification subscriptions. { - Name: "NotificationPushSubscription", + Name: "WebpushSubscription", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, - Resource: rbac.ResourceNotificationPushSubscription.WithOwner(currentUser.String()), + Resource: rbac.ResourceWebpushSubscription.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, memberMe, orgMemberMe}, false: {otherOrgMember, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin}, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 60d232f77c46a..4d0251887444d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3149,8 +3149,8 @@ type BuildInfoResponse struct { // DeploymentID is the unique identifier for this deployment. DeploymentID string `json:"deployment_id"` - // PushNotificationsPublicKey is the public key for push notifications. - PushNotificationsPublicKey string `json:"push_notifications_public_key"` + // WebPushPublicKey is the public key for push notifications. + WebPushPublicKey string `json:"webpush_public_key,omitempty"` } type WorkspaceProxyBuildInfo struct { diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 5dc4617060c8b..41f98a1ce7e15 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -215,30 +215,30 @@ type UpdateUserNotificationPreferences struct { PushSubscription string `json:"push_subscription,omitempty"` } -type PushNotificationAction struct { +type WebpushMessageAction struct { Label string `json:"label"` URL string `json:"url"` } -type PushNotification struct { - Icon string `json:"icon"` - Title string `json:"title"` - Body string `json:"body"` - Actions []PushNotificationAction `json:"actions"` +type WebpushMessage struct { + Icon string `json:"icon"` + Title string `json:"title"` + Body string `json:"body"` + Actions []WebpushMessageAction `json:"actions"` } -type PushNotificationSubscription struct { +type WebpushSubscription struct { Endpoint string `json:"endpoint"` AuthKey string `json:"auth_key"` P256DHKey string `json:"p256dh_key"` } -type DeletePushNotificationSubscription struct { +type DeleteWebpushSubscription struct { Endpoint string `json:"endpoint"` } -// CreateNotificationPushSubscription creates a push notification subscription for a given user. -func (c *Client) CreateNotificationPushSubscription(ctx context.Context, user string, req PushNotificationSubscription) error { +// PostWebpushSubscription creates a push notification subscription for a given user. +func (c *Client) PostWebpushSubscription(ctx context.Context, user string, req WebpushSubscription) error { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) if err != nil { return err @@ -251,9 +251,9 @@ func (c *Client) CreateNotificationPushSubscription(ctx context.Context, user st return nil } -// DeleteNotificationPushSubscription deletes a push notification subscription for a given user. +// DeleteWebpushSubscription deletes a push notification subscription for a given user. // Think of this as an unsubscribe, but for a specific push notification subscription. -func (c *Client) DeleteNotificationPushSubscription(ctx context.Context, user string, req DeletePushNotificationSubscription) error { +func (c *Client) DeleteWebpushSubscription(ctx context.Context, user string, req DeleteWebpushSubscription) error { res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) if err != nil { return err @@ -266,8 +266,8 @@ func (c *Client) DeleteNotificationPushSubscription(ctx context.Context, user st return nil } -func (c *Client) TestPushNotification(ctx context.Context) error { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/test", Me), PushNotification{ +func (c *Client) PostTestWebpushMessage(ctx context.Context) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/test", Me), WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", }) diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index b4137635ec5dd..7f1bd5da4eb3c 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -21,7 +21,6 @@ const ( ResourceLicense RBACResource = "license" ResourceNotificationMessage RBACResource = "notification_message" ResourceNotificationPreference RBACResource = "notification_preference" - ResourceNotificationPushSubscription RBACResource = "notification_push_subscription" ResourceNotificationTemplate RBACResource = "notification_template" ResourceOauth2App RBACResource = "oauth2_app" ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token" @@ -35,6 +34,7 @@ const ( ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" ResourceTemplate RBACResource = "template" ResourceUser RBACResource = "user" + ResourceWebpushSubscription RBACResource = "webpush_subscription" ResourceWorkspace RBACResource = "workspace" ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers" ResourceWorkspaceAgentResourceMonitor RBACResource = "workspace_agent_resource_monitor" @@ -81,7 +81,6 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationMessage: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceNotificationPreference: {ActionRead, ActionUpdate}, - ResourceNotificationPushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationTemplate: {ActionRead, ActionUpdate}, ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead}, @@ -95,6 +94,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceAgentDevcontainers: {ActionCreate}, ResourceWorkspaceAgentResourceMonitor: {ActionCreate, ActionRead, ActionUpdate}, diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 2339fc17d0800..c016ae5ddc8fe 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -58,10 +58,10 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "deployment_id": "string", "external_url": "string", "provisioner_api_version": "string", - "push_notifications_public_key": "string", "telemetry": true, "upgrade_message": "string", "version": "string", + "webpush_public_key": "string", "workspace_proxy": true } ``` diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index d7e0bc473706b..972313001f3ea 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -197,7 +197,6 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | -| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -211,6 +210,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | | `resource_type` | `workspace_agent_resource_monitor` | @@ -363,7 +363,6 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | -| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -377,6 +376,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | | `resource_type` | `workspace_agent_resource_monitor` | @@ -529,7 +529,6 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | -| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -543,6 +542,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | | `resource_type` | `workspace_agent_resource_monitor` | @@ -664,7 +664,6 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | -| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -678,6 +677,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | | `resource_type` | `workspace_agent_resource_monitor` | @@ -1021,7 +1021,6 @@ Status Code **200** | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | -| `resource_type` | `notification_push_subscription` | | `resource_type` | `notification_template` | | `resource_type` | `oauth2_app` | | `resource_type` | `oauth2_app_code_token` | @@ -1035,6 +1034,7 @@ Status Code **200** | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | | `resource_type` | `user` | +| `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | | `resource_type` | `workspace_agent_resource_monitor` | diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index c30dbc08c27b6..0bbb3ab39a9b7 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -536,10 +536,10 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/sub ### Parameters -| Name | In | Type | Required | Description | -|--------|------|------------------------------------------------------------------------------------------|----------|--------------------------------| -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.PushNotificationSubscription](schemas.md#codersdkpushnotificationsubscription) | true | Push notification subscription | +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.WebpushSubscription](schemas.md#codersdkwebpushsubscription) | true | Webpush subscription | ### Responses @@ -572,10 +572,10 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/notifications/push/s ### Parameters -| Name | In | Type | Required | Description | -|--------|------|------------------------------------------------------------------------------------------------------|----------|--------------------------------| -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.DeletePushNotificationSubscription](schemas.md#codersdkdeletepushnotificationsubscription) | true | Push notification subscription | +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|--------------------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.DeleteWebpushSubscription](schemas.md#codersdkdeletewebpushsubscription) | true | Push notification subscription | ### Responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 51c29dbf200f9..63f4814b49bd3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -961,28 +961,28 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "deployment_id": "string", "external_url": "string", "provisioner_api_version": "string", - "push_notifications_public_key": "string", "telemetry": true, "upgrade_message": "string", "version": "string", + "webpush_public_key": "string", "workspace_proxy": true } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | -| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | -| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | -| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | -| `provisioner_api_version` | string | false | | Provisioner api version is the current version of the Provisioner API | -| `push_notifications_public_key` | string | false | | Push notifications public key is the public key for push notifications. | -| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | -| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | -| `version` | string | false | | Version returns the semantic version of the build. | -| `workspace_proxy` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_api_version` | string | false | | Agent api version is the current version of the Agent API (back versions MAY still be supported). | +| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | +| `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | +| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | +| `provisioner_api_version` | string | false | | Provisioner api version is the current version of the Provisioner API | +| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | +| `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | +| `version` | string | false | | Version returns the semantic version of the build. | +| `webpush_public_key` | string | false | | Webpush public key is the public key for push notifications. | +| `workspace_proxy` | boolean | false | | | ## codersdk.BuildReason @@ -1757,7 +1757,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `allow_path_app_sharing` | boolean | false | | | | `allow_path_app_site_owner_access` | boolean | false | | | -## codersdk.DeletePushNotificationSubscription +## codersdk.DeleteWebpushSubscription ```json { @@ -5260,24 +5260,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `unhealthy` | | `unregistered` | -## codersdk.PushNotificationSubscription - -```json -{ - "auth_key": "string", - "endpoint": "string", - "p256dh_key": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|--------------|--------|----------|--------------|-------------| -| `auth_key` | string | false | | | -| `endpoint` | string | false | | | -| `p256dh_key` | string | false | | | - ## codersdk.PutExtendWorkspaceRequest ```json @@ -5366,7 +5348,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `license` | | `notification_message` | | `notification_preference` | -| `notification_push_subscription` | | `notification_template` | | `oauth2_app` | | `oauth2_app_code_token` | @@ -5380,6 +5361,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `tailnet_coordinator` | | `template` | | `user` | +| `webpush_subscription` | | `workspace` | | `workspace_agent_devcontainers` | | `workspace_agent_resource_monitor` | @@ -7508,6 +7490,24 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `name` | string | false | | | | `value` | string | false | | | +## codersdk.WebpushSubscription + +```json +{ + "auth_key": "string", + "endpoint": "string", + "p256dh_key": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------|----------|--------------|-------------| +| `auth_key` | string | false | | | +| `endpoint` | string | false | | | +| `p256dh_key` | string | false | | | + ## codersdk.Workspace ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 33609e4765638..c54dbbfbc6466 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2383,7 +2383,7 @@ class ApiMethods { deleteNotificationPushSubscription = async ( userId: string, - req: TypesGen.DeletePushNotificationSubscription, + req: TypesGen.DeleteWebpushSubscription, ) => { await this.axios.delete( `/api/v2/users/${userId}/notifications/push/subscription`, diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 83f95ea75ad38..6bced3929c43f 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -84,10 +84,10 @@ export const RBACResourceActions: Partial< read: "read notification preferences", update: "update notification preferences", }, - notification_push_subscription: { - create: "create notification push subscriptions", - delete: "delete notification push subscriptions", - read: "read notification push subscriptions", + webpush_subscription: { + create: "create webpush subscriptions", + delete: "delete webpush subscriptions", + read: "read webpush subscriptions", }, notification_template: { read: "read notification templates", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index db00007e84cc6..f4a63097dad57 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -599,7 +599,7 @@ export interface DatabaseReport extends BaseReport { } // From codersdk/notifications.go -export interface DeletePushNotificationSubscription { +export interface DeleteWebpushSubscription { readonly endpoint: string; } @@ -1988,7 +1988,7 @@ export type RBACResource = | "license" | "notification_message" | "notification_preference" - | "notification_push_subscription" + | "webpush_subscription" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" @@ -2026,7 +2026,6 @@ export const RBACResources: RBACResource[] = [ "license", "notification_message", "notification_preference", - "notification_push_subscription", "notification_template", "oauth2_app", "oauth2_app_code_token", @@ -2041,6 +2040,7 @@ export const RBACResources: RBACResource[] = [ "template", "user", "*", + "webpush_subscription", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", From 29bba0418487bd3861f5c7826b6b481d5f34c3ec Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 12:51:25 +0000 Subject: [PATCH 21/40] notification -> webpush --- cli/server.go | 16 ++++----- cli/server_regenerate_vapid_keypair.go | 4 +-- coderd/apidoc/docs.go | 10 +++--- coderd/apidoc/swagger.json | 10 +++--- coderd/coderd.go | 8 ++--- coderd/coderdtest/coderdtest.go | 4 +-- coderd/notifications.go | 8 ++--- coderd/notifications/push/push.go | 32 ++++++++--------- coderd/notifications/push/push_test.go | 2 +- coderd/notifications_test.go | 4 +-- codersdk/deployment.go | 2 +- docs/reference/api/notifications.md | 4 +-- docs/reference/api/schemas.md | 2 +- site/src/api/typesGenerated.ts | 48 +++++++++++++------------- 14 files changed, 77 insertions(+), 77 deletions(-) diff --git a/cli/server.go b/cli/server.go index 25d7ee4e9d4f6..eaf9555414fa6 100644 --- a/cli/server.go +++ b/cli/server.go @@ -779,18 +779,18 @@ 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) { - pushNotifier, err := push.New(ctx, &options.Logger, options.Database) + webpusher, err := push.New(ctx, &options.Logger, options.Database) if err != nil { - options.Logger.Error(ctx, "failed to create push notifier", slog.Error(err)) - options.Logger.Warn(ctx, "push notifications will not work until the VAPID keys are regenerated") - pushNotifier = &push.NoopNotifier{ - Msg: "Push notifications are disabled due to a system error. Please contact your Coder administrator.", + 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") + webpusher = &push.NoopWebpusher{ + Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.", } } - options.PushNotifier = pushNotifier + options.WebpushDispatcher = webpusher } else { - options.PushNotifier = &push.NoopNotifier{ - Msg: "Push notifications are disabled. Enable the 'web-push' experiment to use this feature.", + options.WebpushDispatcher = &push.NoopWebpusher{ + Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.", } } diff --git a/cli/server_regenerate_vapid_keypair.go b/cli/server_regenerate_vapid_keypair.go index 8445712bdd840..1ecf7b42c36d7 100644 --- a/cli/server_regenerate_vapid_keypair.go +++ b/cli/server_regenerate_vapid_keypair.go @@ -25,7 +25,7 @@ func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command { ) regenerateVapidKeypairCommand := &serpent.Command{ Use: "regenerate-vapid-keypair", - Short: "Regenerate the VAPID keypair used for push notifications.", + Short: "Regenerate the VAPID keypair used for web push notifications.", Hidden: true, // Hide this command as it's an experimental feature Handler: func(inv *serpent.Invocation) error { var ( @@ -71,7 +71,7 @@ func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command { // Confirm that the user really wants to regenerate the VAPID keypair. cliui.Infof(inv.Stdout, "Regenerating VAPID keypair...") - cliui.Infof(inv.Stdout, "This will delete all existing push notification subscriptions.") + cliui.Infof(inv.Stdout, "This will delete all existing webpush subscriptions.") cliui.Infof(inv.Stdout, "Are you sure you want to continue? (y/N)") if resp, err := cliui.Prompt(inv, cliui.PromptOptions{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4b24973457aaf..74239d6cac466 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7236,8 +7236,8 @@ const docTemplate = `{ "tags": [ "Notifications" ], - "summary": "Create user push notification subscription", - "operationId": "create-user-push-notification-subscription", + "summary": "Create user webpush notification subscription", + "operationId": "create-user-webpush-notification-subscription", "parameters": [ { "description": "Webpush subscription", @@ -7274,8 +7274,8 @@ const docTemplate = `{ "tags": [ "Notifications" ], - "summary": "Delete user push notification subscription", - "operationId": "delete-user-push-notification-subscription", + "summary": "Delete user webpush notification subscription", + "operationId": "delete-user-webpush-notification-subscription", "parameters": [ { "description": "Push notification subscription", @@ -10828,7 +10828,7 @@ const docTemplate = `{ "type": "string" }, "webpush_public_key": { - "description": "WebPushPublicKey is the public key for push notifications.", + "description": "WebPushPublicKey is the public key for push notifications via Web Push.", "type": "string" }, "workspace_proxy": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ec8d0c6ae8680..b12a93414e6c6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6393,8 +6393,8 @@ ], "consumes": ["application/json"], "tags": ["Notifications"], - "summary": "Create user push notification subscription", - "operationId": "create-user-push-notification-subscription", + "summary": "Create user webpush notification subscription", + "operationId": "create-user-webpush-notification-subscription", "parameters": [ { "description": "Webpush subscription", @@ -6427,8 +6427,8 @@ ], "consumes": ["application/json"], "tags": ["Notifications"], - "summary": "Delete user push notification subscription", - "operationId": "delete-user-push-notification-subscription", + "summary": "Delete user webpush notification subscription", + "operationId": "delete-user-webpush-notification-subscription", "parameters": [ { "description": "Push notification subscription", @@ -9640,7 +9640,7 @@ "type": "string" }, "webpush_public_key": { - "description": "WebPushPublicKey is the public key for push notifications.", + "description": "WebPushPublicKey is the public key for push notifications via Web Push.", "type": "string" }, "workspace_proxy": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 71a9504363608..3cf29dc1495c6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -262,8 +262,8 @@ type Options struct { OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock - // PushNotifier is a way to send push notifications to users. - PushNotifier push.NotificationDispatcher + // WebpushDispatcher is a way to send notifications over Web Push. + WebpushDispatcher push.Dispatcher } // @title Coder API @@ -550,7 +550,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, Experiments: experiments, - PushNotifier: options.PushNotifier, + PushNotifier: options.WebpushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( ctx, @@ -1506,7 +1506,7 @@ type API struct { NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher TailnetClientService *tailnet.ClientService // PushNotifier is a way to send push notifications to users. - PushNotifier push.NotificationDispatcher + PushNotifier push.Dispatcher QuotaCommitter atomic.Pointer[proto.QuotaCommitter] AppearanceFetcher atomic.Pointer[appearance.Fetcher] // WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index aa6c23e14f751..3cb15c025a445 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -162,7 +162,7 @@ type Options struct { Logger *slog.Logger StatsBatcher workspacestats.Batcher - PushNotifier push.NotificationDispatcher + PushNotifier push.Dispatcher WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions AllowWorkspaceRenames bool NewTicker func(duration time.Duration) (<-chan time.Time, func()) @@ -541,7 +541,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can TrialGenerator: options.TrialGenerator, RefreshEntitlements: options.RefreshEntitlements, TailnetCoordinator: options.Coordinator, - PushNotifier: options.PushNotifier, + WebpushDispatcher: options.PushNotifier, BaseDERPMap: derpMap, DERPMapUpdateFrequency: 150 * time.Millisecond, CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider, diff --git a/coderd/notifications.go b/coderd/notifications.go index 42b734549239e..de29d16fcde9a 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -327,8 +327,8 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } -// @Summary Create user push notification subscription -// @ID create-user-push-notification-subscription +// @Summary Create user webpush notification subscription +// @ID create-user-webpush-notification-subscription // @Security CoderSessionToken // @Accept json // @Tags Notifications @@ -404,8 +404,8 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ rw.WriteHeader(http.StatusNoContent) } -// @Summary Delete user push notification subscription -// @ID delete-user-push-notification-subscription +// @Summary Delete user webpush notification subscription +// @ID delete-user-webpush-notification-subscription // @Security CoderSessionToken // @Accept json // @Tags Notifications diff --git a/coderd/notifications/push/push.go b/coderd/notifications/push/push.go index 581e040e5b73c..744f31e6c5c67 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/notifications/push/push.go @@ -21,15 +21,15 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// NotificationDispatcher is an interface that can be used to dispatch -// push notifications. -type NotificationDispatcher interface { +// Dispatcher is an interface that can be used to dispatch +// push notifications over Web Push. +type Dispatcher interface { Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error PublicKey() string PrivateKey() string } -// New creates a new push manager to dispatch push notifications. +// New creates a new Dispatcher to dispatch notifications via Web Push. // // This is *not* integrated into the enqueue system unfortunately. // That's because the notifications system has a enqueue system, @@ -37,7 +37,7 @@ type NotificationDispatcher 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) (NotificationDispatcher, error) { +func New(ctx context.Context, log *slog.Logger, db database.Store) (Dispatcher, error) { keys, err := db.GetWebpushVAPIDKeys(ctx) if err != nil { if !errors.Is(err, sql.ErrNoRows) { @@ -57,7 +57,7 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (Notification keys.VapidPrivateKey = newPrivateKey } - return &Notifier{ + return &Webpusher{ store: db, log: log, VAPIDPublicKey: keys.VapidPublicKey, @@ -65,7 +65,7 @@ func New(ctx context.Context, log *slog.Logger, db database.Store) (Notification }, nil } -type Notifier struct { +type Webpusher struct { store database.Store log *slog.Logger @@ -73,7 +73,7 @@ type Notifier struct { VAPIDPrivateKey string } -func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error { +func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error { subscriptions, err := n.store.GetWebpushSubscriptionsByUserID(ctx, userID) if err != nil { return xerrors.Errorf("get notification push subscriptions by user ID: %w", err) @@ -122,7 +122,7 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification if resp.StatusCode > http.StatusAccepted { // It's likely the subscription failed to deliver for some reason. body, _ := io.ReadAll(resp.Body) - return xerrors.Errorf("push notification failed with status code %d: %s", resp.StatusCode, string(body)) + return xerrors.Errorf("web push dispatch failed with status code %d: %s", resp.StatusCode, string(body)) } return nil @@ -145,30 +145,30 @@ func (n *Notifier) Dispatch(ctx context.Context, userID uuid.UUID, notification return nil } -func (n *Notifier) PublicKey() string { +func (n *Webpusher) PublicKey() string { return n.VAPIDPublicKey } -func (n *Notifier) PrivateKey() string { +func (n *Webpusher) PrivateKey() string { return n.VAPIDPrivateKey } -// NoopNotifier is a Notifier that does nothing except return an error. +// NoopWebpusher is a Dispatcher that does nothing except return an error. // This is returned when push notifications are disabled, or if there was an // error generating the VAPID keys. -type NoopNotifier struct { +type NoopWebpusher struct { Msg string } -func (n *NoopNotifier) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMessage) error { +func (n *NoopWebpusher) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMessage) error { return xerrors.New(n.Msg) } -func (*NoopNotifier) PublicKey() string { +func (*NoopWebpusher) PublicKey() string { return "" } -func (*NoopNotifier) PrivateKey() string { +func (*NoopWebpusher) PrivateKey() string { return "" } diff --git a/coderd/notifications/push/push_test.go b/coderd/notifications/push/push_test.go index 9318d3fb99845..eae51477ef7df 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/notifications/push/push_test.go @@ -243,7 +243,7 @@ func TestPush(t *testing.T) { } // setupPushTest creates a common test setup for push notification tests -func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (push.NotificationDispatcher, database.Store, string) { +func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (push.Dispatcher, database.Store, string) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) db, _ := dbtestutil.NewDB(t) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 59a8fc60faf04..2a3c0df867687 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -379,7 +379,7 @@ func TestNotificationTest(t *testing.T) { } const ( - // These are valid keys for a push notification subscription. + // These are valid keys for a web push subscription. // DO NOT REUSE THESE IN ANY REAL CODE. validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" @@ -413,7 +413,7 @@ func TestPushNotificationSubscription(t *testing.T) { require.True(t, <-handlerCalled, "handler should have been called") err = memberClient.PostTestWebpushMessage(ctx) - require.NoError(t, err, "test push notification") + require.NoError(t, err, "test web push notification") require.True(t, <-handlerCalled, "handler should have been called again") err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 4d0251887444d..dc0bc36a85d5d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3149,7 +3149,7 @@ type BuildInfoResponse struct { // DeploymentID is the unique identifier for this deployment. DeploymentID string `json:"deployment_id"` - // WebPushPublicKey is the public key for push notifications. + // WebPushPublicKey is the public key for push notifications via Web Push. WebPushPublicKey string `json:"webpush_public_key,omitempty"` } diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 0bbb3ab39a9b7..b7e83289acf92 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -511,7 +511,7 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Create user push notification subscription +## Create user webpush notification subscription ### Code samples @@ -549,7 +549,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/sub To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Delete user push notification subscription +## Delete user webpush notification subscription ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 63f4814b49bd3..d64309b85090f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -981,7 +981,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | | `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | | `version` | string | false | | Version returns the semantic version of the build. | -| `webpush_public_key` | string | false | | Webpush public key is the public key for push notifications. | +| `webpush_public_key` | string | false | | Webpush public key is the public key for push notifications via Web Push. | | `workspace_proxy` | boolean | false | | | ## codersdk.BuildReason diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f4a63097dad57..ac4acab0421ca 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -263,7 +263,7 @@ export interface BuildInfoResponse { readonly provisioner_api_version: string; readonly upgrade_message: string; readonly deployment_id: string; - readonly push_notifications_public_key: string; + readonly webpush_public_key?: string; } // From codersdk/workspacebuilds.go @@ -1903,27 +1903,6 @@ export const ProxyHealthStatuses: ProxyHealthStatus[] = [ "unregistered", ]; -// From codersdk/notifications.go -export interface PushNotification { - readonly icon: string; - readonly title: string; - readonly body: string; - readonly actions: readonly PushNotificationAction[]; -} - -// From codersdk/notifications.go -export interface PushNotificationAction { - readonly label: string; - readonly url: string; -} - -// From codersdk/notifications.go -export interface PushNotificationSubscription { - readonly endpoint: string; - readonly auth_key: string; - readonly p256dh_key: string; -} - // From codersdk/workspaces.go export interface PutExtendWorkspaceRequest { readonly deadline: string; @@ -1988,7 +1967,6 @@ export type RBACResource = | "license" | "notification_message" | "notification_preference" - | "webpush_subscription" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" @@ -2002,6 +1980,7 @@ export type RBACResource = | "tailnet_coordinator" | "template" | "user" + | "webpush_subscription" | "*" | "workspace" | "workspace_agent_devcontainers" @@ -2039,8 +2018,8 @@ export const RBACResources: RBACResource[] = [ "tailnet_coordinator", "template", "user", - "*", "webpush_subscription", + "*", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", @@ -3024,6 +3003,27 @@ export interface VariableValue { readonly value: string; } +// From codersdk/notifications.go +export interface WebpushMessage { + readonly icon: string; + readonly title: string; + readonly body: string; + readonly actions: readonly WebpushMessageAction[]; +} + +// From codersdk/notifications.go +export interface WebpushMessageAction { + readonly label: string; + readonly url: string; +} + +// From codersdk/notifications.go +export interface WebpushSubscription { + readonly endpoint: string; + readonly auth_key: string; + readonly p256dh_key: string; +} + // From healthsdk/healthsdk.go export interface WebsocketReport extends BaseReport { readonly healthy: boolean; From 46c2cd8c612ca88b5e3cee156cdeb4d4a565092e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 13:05:02 +0000 Subject: [PATCH 22/40] move to webpush package --- cli/server.go | 8 ++++---- cli/server_regenerate_vapid_keypair.go | 4 ++-- coderd/coderd.go | 12 ++++++------ coderd/coderdtest/coderdtest.go | 12 ++++++------ coderd/notifications.go | 6 +++--- .../push/push.go => webpush/webpush.go} | 2 +- .../push/push_test.go => webpush/webpush_test.go} | 12 ++++++------ 7 files changed, 28 insertions(+), 28 deletions(-) rename coderd/{notifications/push/push.go => webpush/webpush.go} (99%) rename coderd/{notifications/push/push_test.go => webpush/webpush_test.go} (96%) diff --git a/cli/server.go b/cli/server.go index eaf9555414fa6..4a1af980d7dd2 100644 --- a/cli/server.go +++ b/cli/server.go @@ -62,9 +62,9 @@ import ( "github.com/coder/wgtunnel/tunnelsdk" "github.com/coder/coder/v2/coderd/entitlements" - "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" + "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/clilog" @@ -779,17 +779,17 @@ 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 := push.New(ctx, &options.Logger, options.Database) + webpusher, err := webpush.New(ctx, &options.Logger, options.Database) 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") - webpusher = &push.NoopWebpusher{ + webpusher = &webpush.NoopWebpusher{ Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.", } } options.WebpushDispatcher = webpusher } else { - options.WebpushDispatcher = &push.NoopWebpusher{ + options.WebpushDispatcher = &webpush.NoopWebpusher{ Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.", } } diff --git a/cli/server_regenerate_vapid_keypair.go b/cli/server_regenerate_vapid_keypair.go index 1ecf7b42c36d7..c3748f1b2c859 100644 --- a/cli/server_regenerate_vapid_keypair.go +++ b/cli/server_regenerate_vapid_keypair.go @@ -13,7 +13,7 @@ import ( "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/awsiamrds" - "github.com/coder/coder/v2/coderd/notifications/push" + "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -81,7 +81,7 @@ func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command { return xerrors.Errorf("VAPID keypair regeneration failed: %w", err) } - if _, _, err := push.RegenerateVAPIDKeys(ctx, db); err != nil { + if _, _, err := webpush.RegenerateVAPIDKeys(ctx, db); err != nil { return xerrors.Errorf("regenerate vapid keypair: %w", err) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 3cf29dc1495c6..43adc28816653 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -44,8 +44,8 @@ import ( "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" - "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/runtimeconfig" + "github.com/coder/coder/v2/coderd/webpush" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" @@ -263,7 +263,7 @@ type Options struct { Clock quartz.Clock // WebpushDispatcher is a way to send notifications over Web Push. - WebpushDispatcher push.Dispatcher + WebpushDispatcher webpush.Dispatcher } // @title Coder API @@ -550,7 +550,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, Experiments: experiments, - PushNotifier: options.WebpushDispatcher, + WebpushDispatcher: options.WebpushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( ctx, @@ -585,7 +585,7 @@ func New(options *Options) *API { WorkspaceProxy: false, UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), DeploymentID: api.DeploymentID, - WebPushPublicKey: api.PushNotifier.PublicKey(), + WebPushPublicKey: api.WebpushDispatcher.PublicKey(), Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ @@ -1505,8 +1505,8 @@ type API struct { TailnetCoordinator atomic.Pointer[tailnet.Coordinator] NetworkTelemetryBatcher *tailnet.NetworkTelemetryBatcher TailnetClientService *tailnet.ClientService - // PushNotifier is a way to send push notifications to users. - PushNotifier push.Dispatcher + // WebpushDispatcher is a way to send notifications to users via Web Push. + WebpushDispatcher webpush.Dispatcher QuotaCommitter atomic.Pointer[proto.QuotaCommitter] AppearanceFetcher atomic.Pointer[appearance.Fetcher] // WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 3cb15c025a445..1f85323decda5 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -70,7 +70,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" - "github.com/coder/coder/v2/coderd/notifications/push" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -79,6 +78,7 @@ import ( "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/webpush" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/coderd/workspacestats" @@ -162,7 +162,7 @@ type Options struct { Logger *slog.Logger StatsBatcher workspacestats.Batcher - PushNotifier push.Dispatcher + WebpushDispatcher webpush.Dispatcher WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions AllowWorkspaceRenames bool NewTicker func(duration time.Duration) (<-chan time.Time, func()) @@ -282,13 +282,13 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can require.NoError(t, err, "insert a deployment id") } - if options.PushNotifier == nil { + if options.WebpushDispatcher == nil { // nolint:gocritic // Gets/sets VAPID keys. - pushNotifier, err := push.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database) + pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database) if err != nil { panic(xerrors.Errorf("failed to create push notifier: %w", err)) } - options.PushNotifier = pushNotifier + options.WebpushDispatcher = pushNotifier } if options.DeploymentValues == nil { @@ -541,7 +541,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can TrialGenerator: options.TrialGenerator, RefreshEntitlements: options.RefreshEntitlements, TailnetCoordinator: options.Coordinator, - WebpushDispatcher: options.PushNotifier, + WebpushDispatcher: options.WebpushDispatcher, BaseDERPMap: derpMap, DERPMapUpdateFrequency: 150 * time.Millisecond, CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider, diff --git a/coderd/notifications.go b/coderd/notifications.go index de29d16fcde9a..0bc4867a4d1f2 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -366,8 +366,8 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ P256dh: req.P256DHKey, }, }, &webpush.Options{ - VAPIDPublicKey: api.PushNotifier.PublicKey(), - VAPIDPrivateKey: api.PushNotifier.PrivateKey(), + VAPIDPublicKey: api.WebpushDispatcher.PublicKey(), + VAPIDPrivateKey: api.WebpushDispatcher.PrivateKey(), }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -448,7 +448,7 @@ func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Req ctx := r.Context() user := httpmw.UserParam(r) - if err := api.PushNotifier.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ + if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", }); err != nil { diff --git a/coderd/notifications/push/push.go b/coderd/webpush/webpush.go similarity index 99% rename from coderd/notifications/push/push.go rename to coderd/webpush/webpush.go index 744f31e6c5c67..51f2dec4ff09a 100644 --- a/coderd/notifications/push/push.go +++ b/coderd/webpush/webpush.go @@ -1,4 +1,4 @@ -package push +package webpush import ( "context" diff --git a/coderd/notifications/push/push_test.go b/coderd/webpush/webpush_test.go similarity index 96% rename from coderd/notifications/push/push_test.go rename to coderd/webpush/webpush_test.go index eae51477ef7df..2566e0edb348d 100644 --- a/coderd/notifications/push/push_test.go +++ b/coderd/webpush/webpush_test.go @@ -1,4 +1,4 @@ -package push_test +package webpush_test import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/notifications/push" + "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -242,16 +242,16 @@ func TestPush(t *testing.T) { }) } -// setupPushTest creates a common test setup for push notification tests -func setupPushTest(ctx context.Context, t *testing.T, handlerFunc func(w http.ResponseWriter, r *http.Request)) (push.Dispatcher, database.Store, string) { +// 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) { 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 := push.New(ctx, &logger, db) - require.NoError(t, err, "Failed to create push manager") + manager, err := webpush.New(ctx, &logger, db) + require.NoError(t, err, "Failed to create webpush manager") return manager, db, server.URL } From 960c5db836ac751ca44368f8966b0621562ee90d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 14:47:44 +0000 Subject: [PATCH 23/40] webpush: refactor notification test and send logic --- coderd/notifications.go | 45 ++----------------- coderd/notifications_test.go | 54 +++++++++++------------ coderd/webpush/webpush.go | 84 +++++++++++++++++++++++++----------- 3 files changed, 89 insertions(+), 94 deletions(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index 0bc4867a4d1f2..b9330457af42b 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -3,11 +3,8 @@ package coderd import ( "bytes" "encoding/json" - "io" "net/http" - "strconv" - "github.com/SherClockHolmes/webpush-go" "github.com/google/uuid" "cdr.dev/slog" @@ -345,55 +342,21 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ return } - notificationJSON, err := json.Marshal(codersdk.WebpushMessage{ - Title: "It's working!", - Body: "You've subscribed to push notifications.", - }) - if err != nil { + if err := api.WebpushDispatcher.Test(ctx, req); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to marshal notification", + Message: "Failed to test webpush subscription", Detail: err.Error(), }) return } - // Before inserting the subscription into the database, we send a test notification - // to ensure the subscription is valid. - resp, err := webpush.SendNotificationWithContext(r.Context(), notificationJSON, &webpush.Subscription{ - Endpoint: req.Endpoint, - Keys: webpush.Keys{ - Auth: req.AuthKey, - P256dh: req.P256DHKey, - }, - }, &webpush.Options{ - VAPIDPublicKey: api.WebpushDispatcher.PublicKey(), - VAPIDPrivateKey: api.WebpushDispatcher.PrivateKey(), - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to send notification", - Detail: err.Error(), - }) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to send notification. Status code: " + strconv.Itoa(resp.StatusCode), - Detail: string(body), - }) - return - } - - _, err = api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + if _, err := api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ CreatedAt: dbtime.Now(), UserID: user.ID, Endpoint: req.Endpoint, EndpointAuthKey: req.AuthKey, EndpointP256dhKey: req.P256DHKey, - }) - if err != nil { + }); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to insert push notification subscription.", Detail: err.Error(), diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 2a3c0df867687..0fe9a09918e2c 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -385,40 +385,36 @@ const ( validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" ) -func TestPushNotificationSubscription(t *testing.T) { +func TestWebpushSubscribeUnsubscribe(t *testing.T) { t.Parallel() - t.Run("CRUD", func(t *testing.T) { - t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) - ctx := testutil.Context(t, testutil.WaitShort) + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - client := coderdtest.New(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - handlerCalled := make(chan bool, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - handlerCalled <- true - })) - defer server.Close() - - err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{ - Endpoint: server.URL, - AuthKey: validEndpointAuthKey, - P256DHKey: validEndpointP256dhKey, - }) - require.NoError(t, err, "create webpush subscription") - require.True(t, <-handlerCalled, "handler should have been called") + handlerCalled := make(chan bool, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + handlerCalled <- true + })) + defer server.Close() - err = memberClient.PostTestWebpushMessage(ctx) - require.NoError(t, err, "test web push notification") - require.True(t, <-handlerCalled, "handler should have been called again") + err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.NoError(t, err, "create webpush subscription") + require.True(t, <-handlerCalled, "handler should have been called") - err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ - Endpoint: server.URL, - }) - require.NoError(t, err, "delete webpush subscription") + err = memberClient.PostTestWebpushMessage(ctx) + require.NoError(t, err, "test web push notification") + require.True(t, <-handlerCalled, "handler should have been called again") + + err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, }) + require.NoError(t, err, "delete webpush subscription") } diff --git a/coderd/webpush/webpush.go b/coderd/webpush/webpush.go index 51f2dec4ff09a..d4a9a0f0a323e 100644 --- a/coderd/webpush/webpush.go +++ b/coderd/webpush/webpush.go @@ -24,9 +24,12 @@ import ( // Dispatcher is an interface that can be used to dispatch // push notifications over Web Push. type Dispatcher interface { + // Dispatch sends a notification to all subscriptions for a user. Any + // notifications that fail to send are silently dropped. Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error + // Test sends a test notification to a subscription to ensure it is valid. + Test(ctx context.Context, req codersdk.WebpushSubscription) error PublicKey() string - PrivateKey() string } // New creates a new Dispatcher to dispatch notifications via Web Push. @@ -93,24 +96,15 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification for _, subscription := range subscriptions { subscription := subscription eg.Go(func() error { - n.log.Debug(ctx, "dispatching via push", slog.F("subscription", subscription.Endpoint)) - cpy := slices.Clone(notificationJSON) // Need to copy as webpush.SendNotificationWithContext modifies the slice. - resp, err := webpush.SendNotificationWithContext(ctx, cpy, &webpush.Subscription{ - Endpoint: subscription.Endpoint, - Keys: webpush.Keys{ - Auth: subscription.EndpointAuthKey, - P256dh: subscription.EndpointP256dhKey, - }, - }, &webpush.Options{ - VAPIDPublicKey: n.VAPIDPublicKey, - VAPIDPrivateKey: n.VAPIDPrivateKey, + statusCode, body, err := n.webpushSend(ctx, notificationJSON, subscription.Endpoint, webpush.Keys{ + Auth: subscription.EndpointAuthKey, + P256dh: subscription.EndpointP256dhKey, }) if err != nil { return xerrors.Errorf("send notification: %w", err) } - defer resp.Body.Close() - if resp.StatusCode == http.StatusGone { + if statusCode == http.StatusGone { // The subscription is no longer valid, remove it. mu.Lock() cleanupSubscriptions = append(cleanupSubscriptions, subscription.ID) @@ -119,10 +113,9 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification } // 200, 201, and 202 are common for successful delivery. - if resp.StatusCode > http.StatusAccepted { + if statusCode > http.StatusAccepted { // It's likely the subscription failed to deliver for some reason. - body, _ := io.ReadAll(resp.Body) - return xerrors.Errorf("web push dispatch failed with status code %d: %s", resp.StatusCode, string(body)) + return xerrors.Errorf("web push dispatch failed with status code %d: %s", statusCode, string(body)) } return nil @@ -145,12 +138,55 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification return nil } -func (n *Webpusher) PublicKey() string { - return n.VAPIDPublicKey +func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string, keys webpush.Keys) (int, []byte, error) { + // Copy the message to avoid modifying the original. + cpy := slices.Clone(msg) + resp, err := webpush.SendNotificationWithContext(ctx, cpy, &webpush.Subscription{ + Endpoint: endpoint, + Keys: keys, + }, &webpush.Options{ + VAPIDPublicKey: n.VAPIDPublicKey, + VAPIDPrivateKey: n.VAPIDPrivateKey, + }) + if err != nil { + return -1, nil, xerrors.Errorf("send notification: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return -1, nil, xerrors.Errorf("read response body: %w", err) + } + + return resp.StatusCode, body, nil +} + +func (n *Webpusher) Test(ctx context.Context, req codersdk.WebpushSubscription) error { + notificationJSON, err := json.Marshal(codersdk.WebpushMessage{ + Title: "Test", + Body: "This is a test notification", + }) + if err != nil { + return xerrors.Errorf("marshal notification: %w", err) + } + statusCode, body, err := n.webpushSend(ctx, notificationJSON, req.Endpoint, webpush.Keys{ + Auth: req.AuthKey, + P256dh: req.P256DHKey, + }) + if err != nil { + return xerrors.Errorf("send test notification: %w", err) + } + + // 200, 201, and 202 are common for successful delivery. + if statusCode > http.StatusAccepted { + // It's likely the subscription failed to deliver for some reason. + return xerrors.Errorf("web push dispatch failed with status code %d: %s", statusCode, string(body)) + } + + return nil } -func (n *Webpusher) PrivateKey() string { - return n.VAPIDPrivateKey +func (n *Webpusher) PublicKey() string { + return n.VAPIDPublicKey } // NoopWebpusher is a Dispatcher that does nothing except return an error. @@ -164,11 +200,11 @@ func (n *NoopWebpusher) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMes return xerrors.New(n.Msg) } -func (*NoopWebpusher) PublicKey() string { - return "" +func (n *NoopWebpusher) Test(context.Context, codersdk.WebpushSubscription) error { + return xerrors.New(n.Msg) } -func (*NoopWebpusher) PrivateKey() string { +func (*NoopWebpusher) PublicKey() string { return "" } From aa2216140ed6fd1e928379a6f4c06efdda367c51 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 14:57:06 +0000 Subject: [PATCH 24/40] move endpoints under webpush beside notifications --- coderd/apidoc/docs.go | 212 +++++++++++----------- coderd/apidoc/swagger.json | 192 ++++++++++---------- coderd/coderd.go | 10 +- coderd/database/dbauthz/dbauthz.go | 28 +-- coderd/database/dbmem/dbmem.go | 56 +++--- coderd/database/dbmetrics/querymetrics.go | 28 +-- coderd/notifications.go | 16 +- coderd/webpush/webpush.go | 1 + codersdk/notifications.go | 6 +- docs/reference/api/notifications.md | 24 +-- site/src/api/api.ts | 6 +- 11 files changed, 290 insertions(+), 289 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 74239d6cac466..7e54be98845ac 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7223,112 +7223,6 @@ const docTemplate = `{ } } }, - "/users/{user}/notifications/push/subscription": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "tags": [ - "Notifications" - ], - "summary": "Create user webpush notification subscription", - "operationId": "create-user-webpush-notification-subscription", - "parameters": [ - { - "description": "Webpush subscription", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.WebpushSubscription" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - }, - "delete": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "tags": [ - "Notifications" - ], - "summary": "Delete user webpush notification subscription", - "operationId": "delete-user-webpush-notification-subscription", - "parameters": [ - { - "description": "Push notification subscription", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/users/{user}/notifications/push/test": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "tags": [ - "Notifications" - ], - "summary": "Send a test push notification", - "operationId": "send-a-test-push-notification", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - } - }, "/users/{user}/organizations": { "get": { "security": [ @@ -7725,6 +7619,112 @@ const docTemplate = `{ } } }, + "/users/{user}/webpush/subscription": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Create user webpush subscription", + "operationId": "create-user-webpush-subscription", + "parameters": [ + { + "description": "Webpush subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.WebpushSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Delete user webpush subscription", + "operationId": "delete-user-webpush-subscription", + "parameters": [ + { + "description": "Webpush subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/users/{user}/webpush/test": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Notifications" + ], + "summary": "Send a test push notification", + "operationId": "send-a-test-push-notification", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/workspace/{workspacename}": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b12a93414e6c6..26068207bb8cb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6384,102 +6384,6 @@ } } }, - "/users/{user}/notifications/push/subscription": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "tags": ["Notifications"], - "summary": "Create user webpush notification subscription", - "operationId": "create-user-webpush-notification-subscription", - "parameters": [ - { - "description": "Webpush subscription", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.WebpushSubscription" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - }, - "delete": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "tags": ["Notifications"], - "summary": "Delete user webpush notification subscription", - "operationId": "delete-user-webpush-notification-subscription", - "parameters": [ - { - "description": "Push notification subscription", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/users/{user}/notifications/push/test": { - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "tags": ["Notifications"], - "summary": "Send a test push notification", - "operationId": "send-a-test-push-notification", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - } - } - }, "/users/{user}/organizations": { "get": { "security": [ @@ -6830,6 +6734,102 @@ } } }, + "/users/{user}/webpush/subscription": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Notifications"], + "summary": "Create user webpush subscription", + "operationId": "create-user-webpush-subscription", + "parameters": [ + { + "description": "Webpush subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.WebpushSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Notifications"], + "summary": "Delete user webpush subscription", + "operationId": "delete-user-webpush-subscription", + "parameters": [ + { + "description": "Webpush subscription", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeleteWebpushSubscription" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/users/{user}/webpush/test": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Notifications"], + "summary": "Send a test push notification", + "operationId": "send-a-test-push-notification", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/{user}/workspace/{workspacename}": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 43adc28816653..0b464135c0b8a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1200,11 +1200,11 @@ func New(options *Options) *API { r.Get("/", api.userNotificationPreferences) r.Put("/", api.putUserNotificationPreferences) }) - r.Route("/push", func(r chi.Router) { - r.Post("/subscription", api.postUserWebpushSubscription) - r.Delete("/subscription", api.deleteUserWebpushSubscription) - r.Post("/test", api.postUserPushNotificationTest) - }) + }) + r.Route("/webpush", func(r chi.Router) { + r.Post("/subscription", api.postUserWebpushSubscription) + r.Delete("/subscription", api.deleteUserWebpushSubscription) + r.Post("/test", api.postUserPushNotificationTest) }) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c0f4527f2b7c3..87778c66d3dab 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1033,20 +1033,6 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole) return nil } -func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.GetWebpushVAPIDKeysRow{}, err - } - return q.db.GetWebpushVAPIDKeys(ctx) -} - -func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertWebpushVAPIDKeys(ctx, arg) -} - func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -2707,6 +2693,13 @@ func (q *querier) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uu return q.db.GetWebpushSubscriptionsByUserID(ctx, userID) } +func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return database.GetWebpushVAPIDKeysRow{}, err + } + return q.db.GetWebpushVAPIDKeys(ctx) +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { @@ -4721,6 +4714,13 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error { return q.db.UpsertTemplateUsageStats(ctx) } +func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertWebpushVAPIDKeys(ctx, arg) +} + func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index eaa1da65d1faa..d7f8d3a4af50d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1378,34 +1378,6 @@ func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue( return jobs, nil } -func (q *FakeQuerier) GetWebpushVAPIDKeys(_ context.Context) (database.GetWebpushVAPIDKeysRow, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - if q.webpushVAPIDPublicKey == "" && q.webpushVAPIDPrivateKey == "" { - return database.GetWebpushVAPIDKeysRow{}, sql.ErrNoRows - } - - return database.GetWebpushVAPIDKeysRow{ - VapidPublicKey: q.webpushVAPIDPublicKey, - VapidPrivateKey: q.webpushVAPIDPrivateKey, - }, nil -} - -func (q *FakeQuerier) UpsertWebpushVAPIDKeys(_ context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - q.webpushVAPIDPublicKey = arg.VapidPublicKey - q.webpushVAPIDPrivateKey = arg.VapidPrivateKey - return nil -} - func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -6800,6 +6772,20 @@ func (q *FakeQuerier) GetWebpushSubscriptionsByUserID(_ context.Context, userID return out, nil } +func (q *FakeQuerier) GetWebpushVAPIDKeys(_ context.Context) (database.GetWebpushVAPIDKeysRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.webpushVAPIDPublicKey == "" && q.webpushVAPIDPrivateKey == "" { + return database.GetWebpushVAPIDKeysRow{}, sql.ErrNoRows + } + + return database.GetWebpushVAPIDKeysRow{ + VapidPublicKey: q.webpushVAPIDPublicKey, + VapidPrivateKey: q.webpushVAPIDPrivateKey, + }, nil +} + func (q *FakeQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -12562,6 +12548,20 @@ TemplateUsageStatsInsertLoop: return nil } +func (q *FakeQuerier) UpsertWebpushVAPIDKeys(_ context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + q.webpushVAPIDPublicKey = arg.VapidPublicKey + q.webpushVAPIDPrivateKey = arg.VapidPrivateKey + return nil +} + func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 18bd9b109d86a..05c0418c77acd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -88,20 +88,6 @@ func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) return r0 } -func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { - start := time.Now() - r0, r1 := m.s.GetWebpushVAPIDKeys(ctx) - m.queryLatencies.WithLabelValues("GetWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) - return r0, r1 -} - -func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { - start := time.Now() - r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg) - m.queryLatencies.WithLabelValues("UpsertWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() err := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -1544,6 +1530,13 @@ func (m queryMetricsStore) GetWebpushSubscriptionsByUserID(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushVAPIDKeysRow, error) { + start := time.Now() + r0, r1 := m.s.GetWebpushVAPIDKeys(ctx) + m.queryLatencies.WithLabelValues("GetWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) @@ -3063,6 +3056,13 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error { return r0 } +func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { + start := time.Now() + r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWebpushVAPIDKeys").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { start := time.Now() r0, r1 := m.s.UpsertWorkspaceAgentPortShare(ctx, arg) diff --git a/coderd/notifications.go b/coderd/notifications.go index b9330457af42b..43c3b88462f47 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -324,14 +324,14 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } -// @Summary Create user webpush notification subscription -// @ID create-user-webpush-notification-subscription +// @Summary Create user webpush subscription +// @ID create-user-webpush-subscription // @Security CoderSessionToken // @Accept json // @Tags Notifications // @Param request body codersdk.WebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" -// @Router /users/{user}/notifications/push/subscription [post] +// @Router /users/{user}/webpush/subscription [post] // @Success 204 func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -367,14 +367,14 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ rw.WriteHeader(http.StatusNoContent) } -// @Summary Delete user webpush notification subscription -// @ID delete-user-webpush-notification-subscription +// @Summary Delete user webpush subscription +// @ID delete-user-webpush-subscription // @Security CoderSessionToken // @Accept json // @Tags Notifications -// @Param request body codersdk.DeleteWebpushSubscription true "Push notification subscription" +// @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" -// @Router /users/{user}/notifications/push/subscription [delete] +// @Router /users/{user}/webpush/subscription [delete] // @Success 204 func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -406,7 +406,7 @@ func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Re // @Tags Notifications // @Param user path string true "User ID, name, or me" // @Success 204 -// @Router /users/{user}/notifications/push/test [post] +// @Router /users/{user}/webpush/test [post] func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) diff --git a/coderd/webpush/webpush.go b/coderd/webpush/webpush.go index d4a9a0f0a323e..4a05b4a069f4d 100644 --- a/coderd/webpush/webpush.go +++ b/coderd/webpush/webpush.go @@ -29,6 +29,7 @@ type Dispatcher interface { Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error // Test sends a test notification to a subscription to ensure it is valid. Test(ctx context.Context, req codersdk.WebpushSubscription) error + // PublicKey returns the VAPID public key for the webpush dispatcher. PublicKey() string } diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 41f98a1ce7e15..bc0c83f8fe4fc 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -239,7 +239,7 @@ type DeleteWebpushSubscription struct { // PostWebpushSubscription creates a push notification subscription for a given user. func (c *Client) PostWebpushSubscription(ctx context.Context, user string, req WebpushSubscription) error { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req) if err != nil { return err } @@ -254,7 +254,7 @@ func (c *Client) PostWebpushSubscription(ctx context.Context, user string, req W // DeleteWebpushSubscription deletes a push notification subscription for a given user. // Think of this as an unsubscribe, but for a specific push notification subscription. func (c *Client) DeleteWebpushSubscription(ctx context.Context, user string, req DeleteWebpushSubscription) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/notifications/push/subscription", user), req) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/webpush/subscription", user), req) if err != nil { return err } @@ -267,7 +267,7 @@ func (c *Client) DeleteWebpushSubscription(ctx context.Context, user string, req } func (c *Client) PostTestWebpushMessage(ctx context.Context) error { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/notifications/push/test", Me), WebpushMessage{ + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/webpush/test", Me), WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", }) diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index b7e83289acf92..a72ee04dae40d 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -511,18 +511,18 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Create user webpush notification subscription +## Create user webpush subscription ### Code samples ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/subscription \ +curl -X POST http://coder-server:8080/api/v2/users/{user}/webpush/subscription \ -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /users/{user}/notifications/push/subscription` +`POST /users/{user}/webpush/subscription` > Body parameter @@ -549,18 +549,18 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/sub To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Delete user webpush notification subscription +## Delete user webpush subscription ### Code samples ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/users/{user}/notifications/push/subscription \ +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/webpush/subscription \ -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /users/{user}/notifications/push/subscription` +`DELETE /users/{user}/webpush/subscription` > Body parameter @@ -572,10 +572,10 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/notifications/push/s ### Parameters -| Name | In | Type | Required | Description | -|--------|------|------------------------------------------------------------------------------------|----------|--------------------------------| -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.DeleteWebpushSubscription](schemas.md#codersdkdeletewebpushsubscription) | true | Push notification subscription | +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.DeleteWebpushSubscription](schemas.md#codersdkdeletewebpushsubscription) | true | Webpush subscription | ### Responses @@ -591,11 +591,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/users/{user}/notifications/push/test \ +curl -X POST http://coder-server:8080/api/v2/users/{user}/webpush/test \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /users/{user}/notifications/push/test` +`POST /users/{user}/webpush/test` ### Parameters diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c54dbbfbc6466..734be6607c3bf 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2373,10 +2373,10 @@ class ApiMethods { createNotificationPushSubscription = async ( userId: string, - req: TypesGen.PushNotificationSubscription, + req: TypesGen.WebpushSubscription, ) => { await this.axios.post( - `/api/v2/users/${userId}/notifications/push/subscription`, + `/api/v2/users/${userId}/webpush/subscription`, req, ); }; @@ -2386,7 +2386,7 @@ class ApiMethods { req: TypesGen.DeleteWebpushSubscription, ) => { await this.axios.delete( - `/api/v2/users/${userId}/notifications/push/subscription`, + `/api/v2/users/${userId}/webpush/subscription`, { data: req, }, From 57d84a962c10c4fd4d32c90a295126a1b4ba1441 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 15:06:15 +0000 Subject: [PATCH 25/40] skip apidocgen for push notification endpoints --- coderd/apidoc/docs.go | 9 +++ coderd/apidoc/swagger.json | 9 +++ coderd/notifications.go | 3 + docs/reference/api/notifications.md | 100 ---------------------------- 4 files changed, 21 insertions(+), 100 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7e54be98845ac..43db95163862b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7656,6 +7656,9 @@ const docTemplate = `{ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } }, "delete": { @@ -7694,6 +7697,9 @@ const docTemplate = `{ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } } }, @@ -7722,6 +7728,9 @@ const docTemplate = `{ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 26068207bb8cb..46530bf726baa 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6767,6 +6767,9 @@ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } }, "delete": { @@ -6801,6 +6804,9 @@ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } } }, @@ -6827,6 +6833,9 @@ "204": { "description": "No Content" } + }, + "x-apidocgen": { + "skip": true } } }, diff --git a/coderd/notifications.go b/coderd/notifications.go index 43c3b88462f47..75c3f0bee6905 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -333,6 +333,7 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [post] // @Success 204 +// @x-apidocgen {"skip": true} func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -376,6 +377,7 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [delete] // @Success 204 +// @x-apidocgen {"skip": true} func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -407,6 +409,7 @@ func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Re // @Param user path string true "User ID, name, or me" // @Success 204 // @Router /users/{user}/webpush/test [post] +// @x-apidocgen {"skip": true} func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index a72ee04dae40d..188f326dc2509 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -510,103 +510,3 @@ Status Code **200** | `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Create user webpush subscription - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/users/{user}/webpush/subscription \ - -H 'Content-Type: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /users/{user}/webpush/subscription` - -> Body parameter - -```json -{ - "auth_key": "string", - "endpoint": "string", - "p256dh_key": "string" -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|------------------------------------------------------------------------|----------|----------------------| -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.WebpushSubscription](schemas.md#codersdkwebpushsubscription) | true | Webpush subscription | - -### Responses - -| Status | Meaning | Description | Schema | -|--------|-----------------------------------------------------------------|-------------|--------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Delete user webpush subscription - -### Code samples - -```shell -# Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/users/{user}/webpush/subscription \ - -H 'Content-Type: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`DELETE /users/{user}/webpush/subscription` - -> Body parameter - -```json -{ - "endpoint": "string" -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|------------------------------------------------------------------------------------|----------|----------------------| -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.DeleteWebpushSubscription](schemas.md#codersdkdeletewebpushsubscription) | true | Webpush subscription | - -### Responses - -| Status | Meaning | Description | Schema | -|--------|-----------------------------------------------------------------|-------------|--------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Send a test push notification - -### Code samples - -```shell -# Example request using curl -curl -X POST http://coder-server:8080/api/v2/users/{user}/webpush/test \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /users/{user}/webpush/test` - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|--------|----------|----------------------| -| `user` | path | string | true | User ID, name, or me | - -### Responses - -| Status | Meaning | Description | Schema | -|--------|-----------------------------------------------------------------|-------------|--------| -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). From ef22b357f406ae659b63d3e92fd86bac0a28e490 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 15:14:28 +0000 Subject: [PATCH 26/40] make webpush endpoints 404 if experiment not enabled --- cli/server.go | 2 ++ coderd/notifications.go | 14 ++++++++++++++ coderd/notifications_test.go | 6 +++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cli/server.go b/cli/server.go index 4a1af980d7dd2..f49e84f0be666 100644 --- a/cli/server.go +++ b/cli/server.go @@ -790,6 +790,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.WebpushDispatcher = webpusher } else { options.WebpushDispatcher = &webpush.NoopWebpusher{ + // Users will likely not see this message as the endpoints return 404 + // if not enabled. Just in case... Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.", } } diff --git a/coderd/notifications.go b/coderd/notifications.go index 75c3f0bee6905..f3f4721001510 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -337,6 +337,10 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } var req codersdk.WebpushSubscription if !httpapi.Read(ctx, rw, r, &req) { @@ -382,6 +386,11 @@ func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Re ctx := r.Context() user := httpmw.UserParam(r) + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } + var req codersdk.DeleteWebpushSubscription if !httpapi.Read(ctx, rw, r, &req) { return @@ -414,6 +423,11 @@ func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Req ctx := r.Context() user := httpmw.UserParam(r) + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } + if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 0fe9a09918e2c..18e6cca80ab77 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -390,7 +390,11 @@ func TestWebpushSubscribeUnsubscribe(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) - client := coderdtest.New(t, nil) + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWebPush)} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dv, + }) owner := coderdtest.CreateFirstUser(t, client) memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) From 9a1a605ea7e83b9bb616a237779f1a22d72028da Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 15:18:34 +0000 Subject: [PATCH 27/40] auth on test notification endpoint --- coderd/notifications.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/coderd/notifications.go b/coderd/notifications.go index f3f4721001510..682fbb02b63bb 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -428,6 +428,12 @@ func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Req return } + // We need to authorize the user to send a push notification to themselves. + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceNotificationMessage.WithOwner(user.ID.String())) { + httpapi.Forbidden(rw) + return + } + if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ Title: "It's working!", Body: "You've subscribed to push notifications.", From e8b60839f79b7ae4aefa512a2bda5b0f03e3fd1a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 15:21:23 +0000 Subject: [PATCH 28/40] fix ts --- site/src/contexts/usePushNotifications.ts | 2 +- site/src/testHelpers/entities.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/usePushNotifications.ts index 2915bc8ced494..aacd2853110b9 100644 --- a/site/src/contexts/usePushNotifications.ts +++ b/site/src/contexts/usePushNotifications.ts @@ -49,7 +49,7 @@ export const usePushNotifications = (): PushNotifications => { const registration = await navigator.serviceWorker.ready; // Note: You'd typically get this key from your server - const vapidPublicKey = buildInfoQuery.data?.push_notifications_public_key; + const vapidPublicKey = buildInfoQuery.data?.webpush_public_key; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c0424a43aa434..f80171122826c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -227,7 +227,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { workspace_proxy: false, upgrade_message: "My custom upgrade message", deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8", - push_notifications_public_key: "fake-public-key", + webpush_public_key: "fake-public-key", telemetry: true, }; From 5331f9cd25cef8eccf1df0604f10fca0a9a5a2ce Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 16:35:21 +0000 Subject: [PATCH 29/40] ui fixes --- ...ications.ts => useWebpushNotifications.ts} | 19 ++++++++---- .../modules/dashboard/Navbar/NavbarView.tsx | 30 ++++++++++++------- site/src/serviceWorker.ts | 7 ++--- 3 files changed, 34 insertions(+), 22 deletions(-) rename site/src/contexts/{usePushNotifications.ts => useWebpushNotifications.ts} (83%) diff --git a/site/src/contexts/usePushNotifications.ts b/site/src/contexts/useWebpushNotifications.ts similarity index 83% rename from site/src/contexts/usePushNotifications.ts rename to site/src/contexts/useWebpushNotifications.ts index aacd2853110b9..21c033d975255 100644 --- a/site/src/contexts/usePushNotifications.ts +++ b/site/src/contexts/useWebpushNotifications.ts @@ -1,10 +1,12 @@ import { API } from "api/api"; import { buildInfo } from "api/queries/buildInfo"; +import { experiments } from "api/queries/experiments"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useEffect, useState } from "react"; import { useQuery } from "react-query"; -interface PushNotifications { +interface WebpushNotifications { + readonly enabled: boolean; readonly subscribed: boolean; readonly loading: boolean; @@ -12,14 +14,21 @@ interface PushNotifications { unsubscribe(): Promise; } -export const usePushNotifications = (): PushNotifications => { +export const useWebpushNotifications = (): WebpushNotifications => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + const enabledExperimentsQuery = useQuery(experiments(metadata.experiments)); const [subscribed, setSubscribed] = useState(false); const [loading, setLoading] = useState(true); + const [enabled, setEnabled] = useState(false); useEffect(() => { + // Check if the experiment is enabled. + if (enabledExperimentsQuery.data?.includes("web-push")) { + setEnabled(true); + } + // Check if browser supports push notifications if (!("Notification" in window) || !("serviceWorker" in navigator)) { setSubscribed(false); @@ -41,14 +50,12 @@ export const usePushNotifications = (): PushNotifications => { }; checkSubscription(); - }, []); + }, [enabledExperimentsQuery.data]); const subscribe = async (): Promise => { try { setLoading(true); const registration = await navigator.serviceWorker.ready; - - // Note: You'd typically get this key from your server const vapidPublicKey = buildInfoQuery.data?.webpush_public_key; const subscription = await registration.pushManager.subscribe({ @@ -66,7 +73,6 @@ export const usePushNotifications = (): PushNotifications => { p256dh_key: json.keys.p256dh, }); - // Send subscription to your server here setSubscribed(true); } catch (error) { console.error("Subscription failed:", error); @@ -96,6 +102,7 @@ export const usePushNotifications = (): PushNotifications => { return { subscribed, + enabled, loading: loading || buildInfoQuery.isLoading, subscribe, unsubscribe, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 03b9765693857..470470b571cb1 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,11 +1,15 @@ import { API } from "api/api"; +import { experiments } from "api/queries/experiments"; import type * as TypesGen from "api/typesGenerated"; +import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { ProxyContextValue } from "contexts/ProxyContext"; -import { usePushNotifications } from "contexts/usePushNotifications"; +import { useWebpushNotifications } from "contexts/useWebpushNotifications"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox"; import type { FC } from "react"; +import { useQuery } from "react-query"; import { NavLink, useLocation } from "react-router-dom"; import { cn } from "utils/cn"; import { DeploymentDropdown } from "./DeploymentDropdown"; @@ -44,8 +48,8 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { - const { subscribed, loading, subscribe, unsubscribe } = - usePushNotifications(); + const { subscribed, enabled, loading, subscribe, unsubscribe } = + useWebpushNotifications(); return (
@@ -59,14 +63,6 @@ export const NavbarView: FC = ({ - {/* // TODO: styling required here. - {subscribed ? ( - - ) : ( - - )} - */} -
{proxyContextValue && (
@@ -83,6 +79,18 @@ export const NavbarView: FC = ({ />
+ {enabled ? ( + subscribed ? ( + + ) : ( + + ) + ) : null} + -import { - type PushNotification, - PushNotificationAction, -} from "api/typesGenerated"; +import type { WebpushMessage } from "api/typesGenerated"; // @ts-ignore declare const self: ServiceWorkerGlobalScope; @@ -21,7 +18,7 @@ self.addEventListener("push", (event) => { return; } - let payload: PushNotification; + let payload: WebpushMessage; try { payload = event.data.json(); } catch (e) { From 449f88236cfb5b3fbf64f58fade63fef72b28297 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 16:36:49 +0000 Subject: [PATCH 30/40] fixup migrations --- ...bscriptions.down.sql => 000312_webpush_subscriptions.down.sql} | 0 ...h_subscriptions.up.sql => 000312_webpush_subscriptions.up.sql} | 0 ...h_subscriptions.up.sql => 000312_webpush_subscriptions.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000311_webpush_subscriptions.down.sql => 000312_webpush_subscriptions.down.sql} (100%) rename coderd/database/migrations/{000311_webpush_subscriptions.up.sql => 000312_webpush_subscriptions.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000311_webpush_subscriptions.up.sql => 000312_webpush_subscriptions.up.sql} (100%) diff --git a/coderd/database/migrations/000311_webpush_subscriptions.down.sql b/coderd/database/migrations/000312_webpush_subscriptions.down.sql similarity index 100% rename from coderd/database/migrations/000311_webpush_subscriptions.down.sql rename to coderd/database/migrations/000312_webpush_subscriptions.down.sql diff --git a/coderd/database/migrations/000311_webpush_subscriptions.up.sql b/coderd/database/migrations/000312_webpush_subscriptions.up.sql similarity index 100% rename from coderd/database/migrations/000311_webpush_subscriptions.up.sql rename to coderd/database/migrations/000312_webpush_subscriptions.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql b/coderd/database/migrations/testdata/fixtures/000312_webpush_subscriptions.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000311_webpush_subscriptions.up.sql rename to coderd/database/migrations/testdata/fixtures/000312_webpush_subscriptions.up.sql From e5fd00eb172568eb485ccf749cb6546740608eb7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 16:52:20 +0000 Subject: [PATCH 31/40] fix check_unstaged.sh again --- scripts/check_unstaged.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_unstaged.sh b/scripts/check_unstaged.sh index a6de5f0204ef8..90d4cad87e4fc 100755 --- a/scripts/check_unstaged.sh +++ b/scripts/check_unstaged.sh @@ -20,7 +20,7 @@ if [[ "$FILES" != "" ]]; then log "These are the changes:" log for file in "${files[@]}"; do - git --no-pager diff "$file" 1>&2 + git --no-pager diff -- "$file" 1>&2 done log From bcf108aa7528dc49d6956022b72e0cee87d6d6c8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 17:07:35 +0000 Subject: [PATCH 32/40] make bee jenn --- ...ver_regenerate-vapid-keypair_--help.golden | 21 ------------------- coderd/rbac/object_gen.go | 20 +++++++++--------- ...ver_regenerate-vapid-keypair_--help.golden | 21 ------------------- site/src/api/rbacresourcesGenerated.ts | 10 ++++----- 4 files changed, 15 insertions(+), 57 deletions(-) delete mode 100644 cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden delete mode 100644 enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden diff --git a/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden b/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden deleted file mode 100644 index 55d01cbcfc560..0000000000000 --- a/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden +++ /dev/null @@ -1,21 +0,0 @@ -coder v0.0.0-devel - -USAGE: - coder server regenerate-vapid-keypair [flags] - - Regenerate the VAPID keypair used for push notifications. - -OPTIONS: - --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) - Type of auth to use when connecting to postgres. - - --postgres-url string, $CODER_PG_CONNECTION_URL - URL of a PostgreSQL database. If empty, the built-in PostgreSQL - deployment will be used (Coder must not be already running in this - case). - - -y, --yes bool - Bypass prompts. - -——— -Run `coder --help` for a list of global options. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 9d515b22749dc..f135f262deb97 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -155,15 +155,6 @@ var ( Type: "notification_preference", } - // ResourceWebpushSubscription - // Valid Actions - // - "ActionCreate" :: create webpush subscriptions - // - "ActionDelete" :: delete webpush subscriptions - // - "ActionRead" :: read webpush subscriptions - ResourceWebpushSubscription = Object{ - Type: "webpush_subscription", - } - // ResourceNotificationTemplate // Valid Actions // - "ActionRead" :: read notification templates @@ -289,6 +280,15 @@ var ( Type: "user", } + // ResourceWebpushSubscription + // Valid Actions + // - "ActionCreate" :: create webpush subscriptions + // - "ActionDelete" :: delete webpush subscriptions + // - "ActionRead" :: read webpush subscriptions + ResourceWebpushSubscription = Object{ + Type: "webpush_subscription", + } + // ResourceWorkspace // Valid Actions // - "ActionApplicationConnect" :: connect to workspace apps via browser @@ -363,7 +363,6 @@ func AllResources() []Objecter { ResourceLicense, ResourceNotificationMessage, ResourceNotificationPreference, - ResourceWebpushSubscription, ResourceNotificationTemplate, ResourceOauth2App, ResourceOauth2AppCodeToken, @@ -377,6 +376,7 @@ func AllResources() []Objecter { ResourceTailnetCoordinator, ResourceTemplate, ResourceUser, + ResourceWebpushSubscription, ResourceWorkspace, ResourceWorkspaceAgentDevcontainers, ResourceWorkspaceAgentResourceMonitor, diff --git a/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden b/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden deleted file mode 100644 index 55d01cbcfc560..0000000000000 --- a/enterprise/cli/testdata/coder_server_regenerate-vapid-keypair_--help.golden +++ /dev/null @@ -1,21 +0,0 @@ -coder v0.0.0-devel - -USAGE: - coder server regenerate-vapid-keypair [flags] - - Regenerate the VAPID keypair used for push notifications. - -OPTIONS: - --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) - Type of auth to use when connecting to postgres. - - --postgres-url string, $CODER_PG_CONNECTION_URL - URL of a PostgreSQL database. If empty, the built-in PostgreSQL - deployment will be used (Coder must not be already running in this - case). - - -y, --yes bool - Bypass prompts. - -——— -Run `coder --help` for a list of global options. diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 6bced3929c43f..ffb5b541e3a4a 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -84,11 +84,6 @@ export const RBACResourceActions: Partial< read: "read notification preferences", update: "update notification preferences", }, - webpush_subscription: { - create: "create webpush subscriptions", - delete: "delete webpush subscriptions", - read: "read webpush subscriptions", - }, notification_template: { read: "read notification templates", update: "update notification templates", @@ -162,6 +157,11 @@ export const RBACResourceActions: Partial< update: "update an existing user", update_personal: "update personal data", }, + webpush_subscription: { + create: "create webpush subscriptions", + delete: "delete webpush subscriptions", + read: "read webpush subscriptions", + }, workspace: { application_connect: "connect to workspace apps via browser", create: "create a new workspace", From eb7b1028b865c89eb6fdfca430487bbf5ff13ab0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 20:54:48 +0000 Subject: [PATCH 33/40] address comments --- cli/server.go | 4 ++-- coderd/coderd.go | 6 +++--- coderd/coderdtest/coderdtest.go | 4 ++-- coderd/database/dbmem/dbmem.go | 2 ++ coderd/notifications.go | 29 ++++++++++++++++++++++++++--- coderd/notifications_test.go | 26 +++++++++++++++++++++++++- coderd/webpush/webpush.go | 4 ++++ 7 files changed, 64 insertions(+), 11 deletions(-) diff --git a/cli/server.go b/cli/server.go index f49e84f0be666..a2574593b18b6 100644 --- a/cli/server.go +++ b/cli/server.go @@ -787,9 +787,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.", } } - options.WebpushDispatcher = webpusher + options.WebPushDispatcher = webpusher } else { - options.WebpushDispatcher = &webpush.NoopWebpusher{ + options.WebPushDispatcher = &webpush.NoopWebpusher{ // Users will likely not see this message as the endpoints return 404 // if not enabled. Just in case... Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.", diff --git a/coderd/coderd.go b/coderd/coderd.go index 0b464135c0b8a..f68ddeadb6e6b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -262,8 +262,8 @@ type Options struct { OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock - // WebpushDispatcher is a way to send notifications over Web Push. - WebpushDispatcher webpush.Dispatcher + // WebPushDispatcher is a way to send notifications over Web Push. + WebPushDispatcher webpush.Dispatcher } // @title Coder API @@ -550,7 +550,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, Experiments: experiments, - WebpushDispatcher: options.WebpushDispatcher, + WebpushDispatcher: options.WebPushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( ctx, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 1f85323decda5..ca0bc25e29647 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -286,7 +286,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can // nolint:gocritic // Gets/sets VAPID keys. pushNotifier, err := webpush.New(dbauthz.AsNotifier(context.Background()), options.Logger, options.Database) if err != nil { - panic(xerrors.Errorf("failed to create push notifier: %w", err)) + panic(xerrors.Errorf("failed to create web push notifier: %w", err)) } options.WebpushDispatcher = pushNotifier } @@ -541,7 +541,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can TrialGenerator: options.TrialGenerator, RefreshEntitlements: options.RefreshEntitlements, TailnetCoordinator: options.Coordinator, - WebpushDispatcher: options.WebpushDispatcher, + WebPushDispatcher: options.WebpushDispatcher, BaseDERPMap: derpMap, DERPMapUpdateFrequency: 150 * time.Millisecond, CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d7f8d3a4af50d..46f2de5a5820e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2453,6 +2453,8 @@ func (q *FakeQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(_ context.Con } func (q *FakeQuerier) DeleteWebpushSubscriptions(_ context.Context, ids []uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() for i, subscription := range q.webpushSubscriptions { if slices.Contains(ids, subscription.ID) { q.webpushSubscriptions[i] = q.webpushSubscriptions[len(q.webpushSubscriptions)-1] diff --git a/coderd/notifications.go b/coderd/notifications.go index 682fbb02b63bb..79d0cb8e8e154 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -2,8 +2,11 @@ package coderd import ( "bytes" + "database/sql" "encoding/json" + "errors" "net/http" + "slices" "github.com/google/uuid" @@ -396,11 +399,31 @@ func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Re return } - err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ + // Return NotFound if the subscription does not exist. + if existing, err := api.Database.GetWebpushSubscriptionsByUserID(ctx, user.ID); err != nil && errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } else if idx := slices.IndexFunc(existing, func(s database.WebpushSubscription) bool { + return s.Endpoint == req.Endpoint + }); idx == -1 { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } + + if err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ UserID: user.ID, Endpoint: req.Endpoint, - }) - if err != nil { + }); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to delete push notification subscription.", Detail: err.Error(), diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 18e6cca80ab77..19426e3744104 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -397,6 +397,7 @@ func TestWebpushSubscribeUnsubscribe(t *testing.T) { }) owner := coderdtest.CreateFirstUser(t, client) memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, anotherMember := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) handlerCalled := make(chan bool, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -414,11 +415,34 @@ func TestWebpushSubscribeUnsubscribe(t *testing.T) { require.True(t, <-handlerCalled, "handler should have been called") err = memberClient.PostTestWebpushMessage(ctx) - require.NoError(t, err, "test web push notification") + require.NoError(t, err, "test webpush message") require.True(t, <-handlerCalled, "handler should have been called again") err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ Endpoint: server.URL, }) require.NoError(t, err, "delete webpush subscription") + + // Deleting the subscription for a non-existent endpoint should return a 404 + err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, + }) + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) + + // Creating a subscription for another user should not be allowed. + err = memberClient.PostWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.WebpushSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.Error(t, err, "create webpush subscription for another user") + + // Deleting a subscription for another user should not be allowed. + err = memberClient.DeleteWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, + }) + require.Error(t, err, "delete webpush subscription for another user") } diff --git a/coderd/webpush/webpush.go b/coderd/webpush/webpush.go index 4a05b4a069f4d..5bc33ea53620c 100644 --- a/coderd/webpush/webpush.go +++ b/coderd/webpush/webpush.go @@ -97,6 +97,8 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification for _, subscription := range subscriptions { subscription := subscription eg.Go(func() error { + // TODO: Implement some retry logic here. For now, this is just a + // best-effort attempt. statusCode, body, err := n.webpushSend(ctx, notificationJSON, subscription.Endpoint, webpush.Keys{ Auth: subscription.EndpointAuthKey, P256dh: subscription.EndpointP256dhKey, @@ -186,6 +188,8 @@ func (n *Webpusher) Test(ctx context.Context, req codersdk.WebpushSubscription) return nil } +// PublicKey returns the VAPID public key for the webpush dispatcher. +// Clients need this, so it's exposed via the BuildInfo endpoint. func (n *Webpusher) PublicKey() string { return n.VAPIDPublicKey } From 9b5ed097771336def9b29c6f9837d50de5a03fe3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 21:08:04 +0000 Subject: [PATCH 34/40] address more comments + renaming --- coderd/notifications.go | 148 -------------------------------- coderd/notifications_test.go | 70 --------------- coderd/webpush.go | 160 +++++++++++++++++++++++++++++++++++ coderd/webpush/webpush.go | 38 ++++----- coderd/webpush_test.go | 82 ++++++++++++++++++ 5 files changed, 261 insertions(+), 237 deletions(-) create mode 100644 coderd/webpush.go create mode 100644 coderd/webpush_test.go diff --git a/coderd/notifications.go b/coderd/notifications.go index 79d0cb8e8e154..670f3625f41bc 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -2,11 +2,8 @@ package coderd import ( "bytes" - "database/sql" "encoding/json" - "errors" "net/http" - "slices" "github.com/google/uuid" @@ -15,7 +12,6 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" @@ -327,150 +323,6 @@ func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.R httpapi.Write(ctx, rw, http.StatusOK, out) } -// @Summary Create user webpush subscription -// @ID create-user-webpush-subscription -// @Security CoderSessionToken -// @Accept json -// @Tags Notifications -// @Param request body codersdk.WebpushSubscription true "Webpush subscription" -// @Param user path string true "User ID, name, or me" -// @Router /users/{user}/webpush/subscription [post] -// @Success 204 -// @x-apidocgen {"skip": true} -func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) - if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { - httpapi.ResourceNotFound(rw) - return - } - - var req codersdk.WebpushSubscription - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - if err := api.WebpushDispatcher.Test(ctx, req); err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to test webpush subscription", - Detail: err.Error(), - }) - return - } - - if _, err := api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ - CreatedAt: dbtime.Now(), - UserID: user.ID, - Endpoint: req.Endpoint, - EndpointAuthKey: req.AuthKey, - EndpointP256dhKey: req.P256DHKey, - }); err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to insert push notification subscription.", - Detail: err.Error(), - }) - return - } - - rw.WriteHeader(http.StatusNoContent) -} - -// @Summary Delete user webpush subscription -// @ID delete-user-webpush-subscription -// @Security CoderSessionToken -// @Accept json -// @Tags Notifications -// @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription" -// @Param user path string true "User ID, name, or me" -// @Router /users/{user}/webpush/subscription [delete] -// @Success 204 -// @x-apidocgen {"skip": true} -func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) - - if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { - httpapi.ResourceNotFound(rw) - return - } - - var req codersdk.DeleteWebpushSubscription - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - // Return NotFound if the subscription does not exist. - if existing, err := api.Database.GetWebpushSubscriptionsByUserID(ctx, user.ID); err != nil && errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Webpush subscription not found.", - }) - return - } else if idx := slices.IndexFunc(existing, func(s database.WebpushSubscription) bool { - return s.Endpoint == req.Endpoint - }); idx == -1 { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Webpush subscription not found.", - }) - return - } - - if err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ - UserID: user.ID, - Endpoint: req.Endpoint, - }); err != nil { - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Webpush subscription not found.", - }) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to delete push notification subscription.", - Detail: err.Error(), - }) - return - } - - rw.WriteHeader(http.StatusNoContent) -} - -// @Summary Send a test push notification -// @ID send-a-test-push-notification -// @Security CoderSessionToken -// @Tags Notifications -// @Param user path string true "User ID, name, or me" -// @Success 204 -// @Router /users/{user}/webpush/test [post] -// @x-apidocgen {"skip": true} -func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) - - if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { - httpapi.ResourceNotFound(rw) - return - } - - // We need to authorize the user to send a push notification to themselves. - if !api.Authorize(r, policy.ActionCreate, rbac.ResourceNotificationMessage.WithOwner(user.ID.String())) { - httpapi.Forbidden(rw) - return - } - - if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ - Title: "It's working!", - Body: "You've subscribed to push notifications.", - }); err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to send test notification", - Detail: err.Error(), - }) - return - } - - rw.WriteHeader(http.StatusNoContent) -} - func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { for _, tmpl := range in { out = append(out, codersdk.NotificationTemplate{ diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 19426e3744104..bae8b8827fe79 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -2,7 +2,6 @@ package coderd_test import ( "net/http" - "net/http/httptest" "slices" "testing" @@ -377,72 +376,3 @@ func TestNotificationTest(t *testing.T) { require.Len(t, sent, 0) }) } - -const ( - // These are valid keys for a web push subscription. - // DO NOT REUSE THESE IN ANY REAL CODE. - validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" - validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" -) - -func TestWebpushSubscribeUnsubscribe(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - - dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentWebPush)} - client := coderdtest.New(t, &coderdtest.Options{ - DeploymentValues: dv, - }) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - _, anotherMember := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - handlerCalled := make(chan bool, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - handlerCalled <- true - })) - defer server.Close() - - err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{ - Endpoint: server.URL, - AuthKey: validEndpointAuthKey, - P256DHKey: validEndpointP256dhKey, - }) - require.NoError(t, err, "create webpush subscription") - require.True(t, <-handlerCalled, "handler should have been called") - - err = memberClient.PostTestWebpushMessage(ctx) - require.NoError(t, err, "test webpush message") - require.True(t, <-handlerCalled, "handler should have been called again") - - err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ - Endpoint: server.URL, - }) - require.NoError(t, err, "delete webpush subscription") - - // Deleting the subscription for a non-existent endpoint should return a 404 - err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ - Endpoint: server.URL, - }) - var sdkError *codersdk.Error - require.Error(t, err) - require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") - require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) - - // Creating a subscription for another user should not be allowed. - err = memberClient.PostWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.WebpushSubscription{ - Endpoint: server.URL, - AuthKey: validEndpointAuthKey, - P256DHKey: validEndpointP256dhKey, - }) - require.Error(t, err, "create webpush subscription for another user") - - // Deleting a subscription for another user should not be allowed. - err = memberClient.DeleteWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.DeleteWebpushSubscription{ - Endpoint: server.URL, - }) - require.Error(t, err, "delete webpush subscription for another user") -} diff --git a/coderd/webpush.go b/coderd/webpush.go new file mode 100644 index 0000000000000..893401552df49 --- /dev/null +++ b/coderd/webpush.go @@ -0,0 +1,160 @@ +package coderd + +import ( + "database/sql" + "errors" + "net/http" + "slices" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Create user webpush subscription +// @ID create-user-webpush-subscription +// @Security CoderSessionToken +// @Accept json +// @Tags Notifications +// @Param request body codersdk.WebpushSubscription true "Webpush subscription" +// @Param user path string true "User ID, name, or me" +// @Router /users/{user}/webpush/subscription [post] +// @Success 204 +// @x-apidocgen {"skip": true} +func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.WebpushSubscription + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if err := api.WebpushDispatcher.Test(ctx, req); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to test webpush subscription", + Detail: err.Error(), + }) + return + } + + if _, err := api.Database.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + CreatedAt: dbtime.Now(), + UserID: user.ID, + Endpoint: req.Endpoint, + EndpointAuthKey: req.AuthKey, + EndpointP256dhKey: req.P256DHKey, + }); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert push notification subscription.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// @Summary Delete user webpush subscription +// @ID delete-user-webpush-subscription +// @Security CoderSessionToken +// @Accept json +// @Tags Notifications +// @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription" +// @Param user path string true "User ID, name, or me" +// @Router /users/{user}/webpush/subscription [delete] +// @Success 204 +// @x-apidocgen {"skip": true} +func (api *API) deleteUserWebpushSubscription(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.DeleteWebpushSubscription + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Return NotFound if the subscription does not exist. + if existing, err := api.Database.GetWebpushSubscriptionsByUserID(ctx, user.ID); err != nil && errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } else if idx := slices.IndexFunc(existing, func(s database.WebpushSubscription) bool { + return s.Endpoint == req.Endpoint + }); idx == -1 { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } + + if err := api.Database.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ + UserID: user.ID, + Endpoint: req.Endpoint, + }); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Webpush subscription not found.", + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete push notification subscription.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// @Summary Send a test push notification +// @ID send-a-test-push-notification +// @Security CoderSessionToken +// @Tags Notifications +// @Param user path string true "User ID, name, or me" +// @Success 204 +// @Router /users/{user}/webpush/test [post] +// @x-apidocgen {"skip": true} +func (api *API) postUserPushNotificationTest(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + if !api.Experiments.Enabled(codersdk.ExperimentWebPush) { + httpapi.ResourceNotFound(rw) + return + } + + // We need to authorize the user to send a push notification to themselves. + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceNotificationMessage.WithOwner(user.ID.String())) { + httpapi.Forbidden(rw) + return + } + + if err := api.WebpushDispatcher.Dispatch(ctx, user.ID, codersdk.WebpushMessage{ + Title: "It's working!", + Body: "You've subscribed to push notifications.", + }); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to send test notification", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/coderd/webpush/webpush.go b/coderd/webpush/webpush.go index 5bc33ea53620c..a6c0790d2dce1 100644 --- a/coderd/webpush/webpush.go +++ b/coderd/webpush/webpush.go @@ -22,18 +22,18 @@ import ( ) // Dispatcher is an interface that can be used to dispatch -// push notifications over Web Push. +// web push notifications to clients such as browsers. type Dispatcher interface { - // Dispatch sends a notification to all subscriptions for a user. Any - // notifications that fail to send are silently dropped. + // Dispatch sends a web push notification to all subscriptions + // for a user. Any notifications that fail to send are silently dropped. Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error - // Test sends a test notification to a subscription to ensure it is valid. + // Test sends a test web push notificatoin to a subscription to ensure it is valid. Test(ctx context.Context, req codersdk.WebpushSubscription) error // PublicKey returns the VAPID public key for the webpush dispatcher. PublicKey() string } -// New creates a new Dispatcher to dispatch notifications via Web Push. +// New creates a new Dispatcher to dispatch web push notifications. // // This is *not* integrated into the enqueue system unfortunately. // That's because the notifications system has a enqueue system, @@ -77,18 +77,18 @@ type Webpusher struct { VAPIDPrivateKey string } -func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification codersdk.WebpushMessage) error { +func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, msg codersdk.WebpushMessage) error { subscriptions, err := n.store.GetWebpushSubscriptionsByUserID(ctx, userID) if err != nil { - return xerrors.Errorf("get notification push subscriptions by user ID: %w", err) + return xerrors.Errorf("get web push subscriptions by user ID: %w", err) } if len(subscriptions) == 0 { return nil } - notificationJSON, err := json.Marshal(notification) + msgJSON, err := json.Marshal(msg) if err != nil { - return xerrors.Errorf("marshal notification: %w", err) + return xerrors.Errorf("marshal webpush notification: %w", err) } cleanupSubscriptions := make([]uuid.UUID, 0) @@ -99,12 +99,12 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification eg.Go(func() error { // TODO: Implement some retry logic here. For now, this is just a // best-effort attempt. - statusCode, body, err := n.webpushSend(ctx, notificationJSON, subscription.Endpoint, webpush.Keys{ + statusCode, body, err := n.webpushSend(ctx, msgJSON, subscription.Endpoint, webpush.Keys{ Auth: subscription.EndpointAuthKey, P256dh: subscription.EndpointP256dhKey, }) if err != nil { - return xerrors.Errorf("send notification: %w", err) + return xerrors.Errorf("send webpush notification: %w", err) } if statusCode == http.StatusGone { @@ -127,7 +127,7 @@ func (n *Webpusher) Dispatch(ctx context.Context, userID uuid.UUID, notification err = eg.Wait() if err != nil { - return xerrors.Errorf("send push notifications: %w", err) + return xerrors.Errorf("send webpush notifications: %w", err) } if len(cleanupSubscriptions) > 0 { @@ -152,7 +152,7 @@ func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string VAPIDPrivateKey: n.VAPIDPrivateKey, }) if err != nil { - return -1, nil, xerrors.Errorf("send notification: %w", err) + return -1, nil, xerrors.Errorf("send webpush notification: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -164,19 +164,19 @@ func (n *Webpusher) webpushSend(ctx context.Context, msg []byte, endpoint string } func (n *Webpusher) Test(ctx context.Context, req codersdk.WebpushSubscription) error { - notificationJSON, err := json.Marshal(codersdk.WebpushMessage{ + msgJSON, err := json.Marshal(codersdk.WebpushMessage{ Title: "Test", - Body: "This is a test notification", + Body: "This is a test Web Push notification", }) if err != nil { - return xerrors.Errorf("marshal notification: %w", err) + return xerrors.Errorf("marshal webpush notification: %w", err) } - statusCode, body, err := n.webpushSend(ctx, notificationJSON, req.Endpoint, webpush.Keys{ + statusCode, body, err := n.webpushSend(ctx, msgJSON, req.Endpoint, webpush.Keys{ Auth: req.AuthKey, P256dh: req.P256DHKey, }) if err != nil { - return xerrors.Errorf("send test notification: %w", err) + return xerrors.Errorf("send test webpush notification: %w", err) } // 200, 201, and 202 are common for successful delivery. @@ -195,7 +195,7 @@ func (n *Webpusher) PublicKey() string { } // NoopWebpusher is a Dispatcher that does nothing except return an error. -// This is returned when push notifications are disabled, or if there was an +// This is returned when web push notifications are disabled, or if there was an // error generating the VAPID keys. type NoopWebpusher struct { Msg string diff --git a/coderd/webpush_test.go b/coderd/webpush_test.go new file mode 100644 index 0000000000000..f41639b99e21d --- /dev/null +++ b/coderd/webpush_test.go @@ -0,0 +1,82 @@ +package coderd_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +const ( + // These are valid keys for a web push subscription. + // DO NOT REUSE THESE IN ANY REAL CODE. + validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" + validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +) + +func TestWebpushSubscribeUnsubscribe(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWebPush)} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, anotherMember := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + handlerCalled := make(chan bool, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + handlerCalled <- true + })) + defer server.Close() + + err := memberClient.PostWebpushSubscription(ctx, "me", codersdk.WebpushSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.NoError(t, err, "create webpush subscription") + require.True(t, <-handlerCalled, "handler should have been called") + + err = memberClient.PostTestWebpushMessage(ctx) + require.NoError(t, err, "test webpush message") + require.True(t, <-handlerCalled, "handler should have been called again") + + err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, + }) + require.NoError(t, err, "delete webpush subscription") + + // Deleting the subscription for a non-existent endpoint should return a 404 + err = memberClient.DeleteWebpushSubscription(ctx, "me", codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, + }) + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) + + // Creating a subscription for another user should not be allowed. + err = memberClient.PostWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.WebpushSubscription{ + Endpoint: server.URL, + AuthKey: validEndpointAuthKey, + P256DHKey: validEndpointP256dhKey, + }) + require.Error(t, err, "create webpush subscription for another user") + + // Deleting a subscription for another user should not be allowed. + err = memberClient.DeleteWebpushSubscription(ctx, anotherMember.ID.String(), codersdk.DeleteWebpushSubscription{ + Endpoint: server.URL, + }) + require.Error(t, err, "delete webpush subscription for another user") +} From c06558e40ca51aef96c210229bde2ac7f48c458b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 21:39:33 +0000 Subject: [PATCH 35/40] move out check_unstaged.sh changes to separate PR --- scripts/check_unstaged.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_unstaged.sh b/scripts/check_unstaged.sh index 90d4cad87e4fc..a6de5f0204ef8 100755 --- a/scripts/check_unstaged.sh +++ b/scripts/check_unstaged.sh @@ -20,7 +20,7 @@ if [[ "$FILES" != "" ]]; then log "These are the changes:" log for file in "${files[@]}"; do - git --no-pager diff -- "$file" 1>&2 + git --no-pager diff "$file" 1>&2 done log From 10ac9fb829a0c68aff9591e21c06118dc766655b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 21:44:15 +0000 Subject: [PATCH 36/40] remove site changes --- site/src/api/api.ts | 22 ------------------- site/src/index.tsx | 5 ----- .../modules/dashboard/Navbar/NavbarView.tsx | 22 +------------------ site/src/testHelpers/entities.ts | 1 - site/vite.config.mts | 18 --------------- 5 files changed, 1 insertion(+), 67 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 734be6607c3bf..b042735357ab0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2371,28 +2371,6 @@ class ApiMethods { await this.axios.post("/api/v2/notifications/test"); }; - createNotificationPushSubscription = async ( - userId: string, - req: TypesGen.WebpushSubscription, - ) => { - await this.axios.post( - `/api/v2/users/${userId}/webpush/subscription`, - req, - ); - }; - - deleteNotificationPushSubscription = async ( - userId: string, - req: TypesGen.DeleteWebpushSubscription, - ) => { - await this.axios.delete( - `/api/v2/users/${userId}/webpush/subscription`, - { - data: req, - }, - ); - }; - requestOneTimePassword = async ( req: TypesGen.RequestOneTimePasscodeRequest, ) => { diff --git a/site/src/index.tsx b/site/src/index.tsx index 85d66b9833d3e..aef10d6c64f4d 100644 --- a/site/src/index.tsx +++ b/site/src/index.tsx @@ -14,10 +14,5 @@ if (element === null) { throw new Error("root element is null"); } -// The service worker handles push notifications. -if ("serviceWorker" in navigator) { - navigator.serviceWorker.register("/serviceWorker.js"); -} - const root = createRoot(element); root.render(); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 470470b571cb1..204828c2fd8ac 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,15 +1,10 @@ import { API } from "api/api"; -import { experiments } from "api/queries/experiments"; import type * as TypesGen from "api/typesGenerated"; -import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { ProxyContextValue } from "contexts/ProxyContext"; -import { useWebpushNotifications } from "contexts/useWebpushNotifications"; -import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox"; import type { FC } from "react"; -import { useQuery } from "react-query"; import { NavLink, useLocation } from "react-router-dom"; import { cn } from "utils/cn"; import { DeploymentDropdown } from "./DeploymentDropdown"; @@ -48,9 +43,6 @@ export const NavbarView: FC = ({ canViewAuditLog, proxyContextValue, }) => { - const { subscribed, enabled, loading, subscribe, unsubscribe } = - useWebpushNotifications(); - return (
@@ -63,7 +55,7 @@ export const NavbarView: FC = ({ -
+
{proxyContextValue && (
@@ -79,18 +71,6 @@ export const NavbarView: FC = ({ />
- {enabled ? ( - subscribed ? ( - - ) : ( - - ) - ) : null} - { - return chunkInfo.name === "serviceWorker" - ? "[name].js" - : "assets/[name]-[hash].js"; - }, - }, - }, }, define: { "process.env": { @@ -103,10 +89,6 @@ export default defineConfig({ target: process.env.CODER_HOST || "http://localhost:3000", secure: process.env.NODE_ENV === "production", }, - "/serviceWorker.js": { - target: process.env.CODER_HOST || "http://localhost:3000", - secure: process.env.NODE_ENV === "production", - }, }, allowedHosts: true, }, From a114ddbb30dcdbf46148b50a58fc90dd25003554 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Mar 2025 21:58:35 +0000 Subject: [PATCH 37/40] fixup! remove site changes --- site/src/contexts/useWebpushNotifications.ts | 110 ------------------- site/src/serviceWorker.ts | 39 ------- 2 files changed, 149 deletions(-) delete mode 100644 site/src/contexts/useWebpushNotifications.ts delete mode 100644 site/src/serviceWorker.ts diff --git a/site/src/contexts/useWebpushNotifications.ts b/site/src/contexts/useWebpushNotifications.ts deleted file mode 100644 index 21c033d975255..0000000000000 --- a/site/src/contexts/useWebpushNotifications.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { API } from "api/api"; -import { buildInfo } from "api/queries/buildInfo"; -import { experiments } from "api/queries/experiments"; -import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { useEffect, useState } from "react"; -import { useQuery } from "react-query"; - -interface WebpushNotifications { - readonly enabled: boolean; - readonly subscribed: boolean; - readonly loading: boolean; - - subscribe(): Promise; - unsubscribe(): Promise; -} - -export const useWebpushNotifications = (): WebpushNotifications => { - const { metadata } = useEmbeddedMetadata(); - const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const enabledExperimentsQuery = useQuery(experiments(metadata.experiments)); - - const [subscribed, setSubscribed] = useState(false); - const [loading, setLoading] = useState(true); - const [enabled, setEnabled] = useState(false); - - useEffect(() => { - // Check if the experiment is enabled. - if (enabledExperimentsQuery.data?.includes("web-push")) { - setEnabled(true); - } - - // Check if browser supports push notifications - if (!("Notification" in window) || !("serviceWorker" in navigator)) { - setSubscribed(false); - setLoading(false); - return; - } - - const checkSubscription = async () => { - try { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - setSubscribed(!!subscription); - } catch (error) { - console.error("Error checking push subscription:", error); - setSubscribed(false); - } finally { - setLoading(false); - } - }; - - checkSubscription(); - }, [enabledExperimentsQuery.data]); - - const subscribe = async (): Promise => { - try { - setLoading(true); - const registration = await navigator.serviceWorker.ready; - const vapidPublicKey = buildInfoQuery.data?.webpush_public_key; - - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: vapidPublicKey, - }); - const json = subscription.toJSON(); - if (!json.keys || !json.endpoint) { - throw new Error("No keys or endpoint found"); - } - - await API.createNotificationPushSubscription("me", { - endpoint: json.endpoint, - auth_key: json.keys.auth, - p256dh_key: json.keys.p256dh, - }); - - setSubscribed(true); - } catch (error) { - console.error("Subscription failed:", error); - throw error; - } finally { - setLoading(false); - } - }; - - const unsubscribe = async (): Promise => { - try { - setLoading(true); - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - - if (subscription) { - await subscription.unsubscribe(); - setSubscribed(false); - } - } catch (error) { - console.error("Unsubscription failed:", error); - throw error; - } finally { - setLoading(false); - } - }; - - return { - subscribed, - enabled, - loading: loading || buildInfoQuery.isLoading, - subscribe, - unsubscribe, - }; -}; diff --git a/site/src/serviceWorker.ts b/site/src/serviceWorker.ts deleted file mode 100644 index de7951932aa4a..0000000000000 --- a/site/src/serviceWorker.ts +++ /dev/null @@ -1,39 +0,0 @@ -/// - -import type { WebpushMessage } from "api/typesGenerated"; - -// @ts-ignore -declare const self: ServiceWorkerGlobalScope; - -self.addEventListener("install", (event) => { - self.skipWaiting(); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); -}); - -self.addEventListener("push", (event) => { - if (!event.data) { - return; - } - - let payload: WebpushMessage; - try { - payload = event.data.json(); - } catch (e) { - return; - } - - event.waitUntil( - self.registration.showNotification(payload.title, { - body: payload.body || "", - icon: payload.icon || "/favicon.ico", - }), - ); -}); - -// Handle notification click -self.addEventListener("notificationclick", (event) => { - event.notification.close(); -}); From ca72676981a8e25362a565c97e97f965b64ae773 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Mar 2025 09:13:52 +0000 Subject: [PATCH 38/40] nits --- coderd/webpush.go | 4 ++-- codersdk/notifications.go | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/webpush.go b/coderd/webpush.go index 893401552df49..be1fb0d406792 100644 --- a/coderd/webpush.go +++ b/coderd/webpush.go @@ -19,7 +19,7 @@ import ( // @ID create-user-webpush-subscription // @Security CoderSessionToken // @Accept json -// @Tags Notifications +// @Tags WebPush // @Param request body codersdk.WebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [post] @@ -67,7 +67,7 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ // @ID delete-user-webpush-subscription // @Security CoderSessionToken // @Accept json -// @Tags Notifications +// @Tags WebPush // @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [delete] diff --git a/codersdk/notifications.go b/codersdk/notifications.go index bc0c83f8fe4fc..9d68c5a01d9c6 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -212,7 +212,6 @@ type UpdateNotificationTemplateMethod struct { type UpdateUserNotificationPreferences struct { TemplateDisabledMap map[string]bool `json:"template_disabled_map"` - PushSubscription string `json:"push_subscription,omitempty"` } type WebpushMessageAction struct { From 3b1dc70dcfd3285d7952d9224f19e07eb5a41904 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Mar 2025 09:22:41 +0000 Subject: [PATCH 39/40] make gen --- coderd/apidoc/docs.go | 7 ++----- coderd/apidoc/swagger.json | 7 ++----- docs/reference/api/notifications.md | 1 - docs/reference/api/schemas.md | 2 -- site/src/api/typesGenerated.ts | 1 - 5 files changed, 4 insertions(+), 14 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 43db95163862b..c844be66f2154 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7630,7 +7630,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Notifications" + "WebPush" ], "summary": "Create user webpush subscription", "operationId": "create-user-webpush-subscription", @@ -7671,7 +7671,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Notifications" + "WebPush" ], "summary": "Delete user webpush subscription", "operationId": "delete-user-webpush-subscription", @@ -15617,9 +15617,6 @@ const docTemplate = `{ "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { - "push_subscription": { - "type": "string" - }, "template_disabled_map": { "type": "object", "additionalProperties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 46530bf726baa..541568a61eb13 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6742,7 +6742,7 @@ } ], "consumes": ["application/json"], - "tags": ["Notifications"], + "tags": ["WebPush"], "summary": "Create user webpush subscription", "operationId": "create-user-webpush-subscription", "parameters": [ @@ -6779,7 +6779,7 @@ } ], "consumes": ["application/json"], - "tags": ["Notifications"], + "tags": ["WebPush"], "summary": "Delete user webpush subscription", "operationId": "delete-user-webpush-subscription", "parameters": [ @@ -14216,9 +14216,6 @@ "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { - "push_subscription": { - "type": "string" - }, "template_disabled_map": { "type": "object", "additionalProperties": { diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md index 188f326dc2509..09890d3b17864 100644 --- a/docs/reference/api/notifications.md +++ b/docs/reference/api/notifications.md @@ -463,7 +463,6 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/notifications/preferenc ```json { - "push_subscription": "string", "template_disabled_map": { "property1": true, "property2": true diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d64309b85090f..4fee5c57d5100 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6866,7 +6866,6 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { - "push_subscription": "string", "template_disabled_map": { "property1": true, "property2": true @@ -6878,7 +6877,6 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | Name | Type | Required | Restrictions | Description | |-------------------------|---------|----------|--------------|-------------| -| `push_subscription` | string | false | | | | `template_disabled_map` | object | false | | | | » `[any property]` | boolean | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ac4acab0421ca..964c3a16d3365 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2780,7 +2780,6 @@ export interface UpdateUserAppearanceSettingsRequest { // From codersdk/notifications.go export interface UpdateUserNotificationPreferences { readonly template_disabled_map: Record; - readonly push_subscription?: string; } // From codersdk/users.go From 12808f13225aa1e3cd3305a2843549a2dd09dfc0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 27 Mar 2025 09:46:52 +0000 Subject: [PATCH 40/40] gen --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- coderd/webpush.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c844be66f2154..a543e5b716e8f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7630,7 +7630,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "WebPush" + "Notifications" ], "summary": "Create user webpush subscription", "operationId": "create-user-webpush-subscription", @@ -7671,7 +7671,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "WebPush" + "Notifications" ], "summary": "Delete user webpush subscription", "operationId": "delete-user-webpush-subscription", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 541568a61eb13..586f63e5c6d6f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6742,7 +6742,7 @@ } ], "consumes": ["application/json"], - "tags": ["WebPush"], + "tags": ["Notifications"], "summary": "Create user webpush subscription", "operationId": "create-user-webpush-subscription", "parameters": [ @@ -6779,7 +6779,7 @@ } ], "consumes": ["application/json"], - "tags": ["WebPush"], + "tags": ["Notifications"], "summary": "Delete user webpush subscription", "operationId": "delete-user-webpush-subscription", "parameters": [ diff --git a/coderd/webpush.go b/coderd/webpush.go index be1fb0d406792..893401552df49 100644 --- a/coderd/webpush.go +++ b/coderd/webpush.go @@ -19,7 +19,7 @@ import ( // @ID create-user-webpush-subscription // @Security CoderSessionToken // @Accept json -// @Tags WebPush +// @Tags Notifications // @Param request body codersdk.WebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [post] @@ -67,7 +67,7 @@ func (api *API) postUserWebpushSubscription(rw http.ResponseWriter, r *http.Requ // @ID delete-user-webpush-subscription // @Security CoderSessionToken // @Accept json -// @Tags WebPush +// @Tags Notifications // @Param request body codersdk.DeleteWebpushSubscription true "Webpush subscription" // @Param user path string true "User ID, name, or me" // @Router /users/{user}/webpush/subscription [delete]