diff --git a/cli/server.go b/cli/server.go index 816fdb6af173c..a2574593b18b6 100644 --- a/cli/server.go +++ b/cli/server.go @@ -64,6 +64,7 @@ import ( "github.com/coder/coder/v2/coderd/entitlements" "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" @@ -775,6 +776,26 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } + // Manage push notifications. + experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()) + if experiments.Enabled(codersdk.ExperimentWebPush) { + webpusher, err := webpush.New(ctx, &options.Logger, options.Database) + if 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 = &webpush.NoopWebpusher{ + Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.", + } + } + 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.", + } + } + githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) if err != nil { return xerrors.Errorf("get github oauth2 config params: %w", err) @@ -1255,6 +1276,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } createAdminUserCmd := r.newCreateAdminUserCommand() + regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand() rawURLOpt := serpent.Option{ Flag: "raw-url", @@ -1268,7 +1290,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..c3748f1b2c859 --- /dev/null +++ b/cli/server_regenerate_vapid_keypair.go @@ -0,0 +1,112 @@ +//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/webpush" + "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 web 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...) + 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 webpush 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 := webpush.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..cbaff3681df11 --- /dev/null +++ b/cli/server_regenerate_vapid_keypair_test.go @@ -0,0 +1,118 @@ +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() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test is only supported on postgres") + } + + 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.GetWebpushVAPIDKeys(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 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.GetWebpushVAPIDKeys(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.WebpushSubscription(t, db, database.InsertWebpushSubscriptionParams{ + 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 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.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 webpush_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..80779201dc796 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -6,12 +6,12 @@ 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. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e570e95a8d9bc..a543e5b716e8f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7619,6 +7619,121 @@ 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" + } + }, + "x-apidocgen": { + "skip": true + } + }, + "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" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, + "/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" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/users/{user}/workspace/{workspacename}": { "get": { "security": [ @@ -10721,6 +10836,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 via Web Push.", + "type": "string" + }, "workspace_proxy": { "type": "boolean" } @@ -11497,6 +11616,14 @@ const docTemplate = `{ } } }, + "codersdk.DeleteWebpushSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + } + }, "codersdk.DeleteWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -11832,19 +11959,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": { @@ -14111,6 +14241,7 @@ const docTemplate = `{ "tailnet_coordinator", "template", "user", + "webpush_subscription", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", @@ -14148,6 +14279,7 @@ const docTemplate = `{ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentResourceMonitor", @@ -15977,6 +16109,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 606cb76ade16c..586f63e5c6d6f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6734,6 +6734,111 @@ } } }, + "/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" + } + }, + "x-apidocgen": { + "skip": true + } + }, + "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" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, + "/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" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/users/{user}/workspace/{workspacename}": { "get": { "security": [ @@ -9543,6 +9648,10 @@ "description": "Version returns the semantic version of the build.", "type": "string" }, + "webpush_public_key": { + "description": "WebPushPublicKey is the public key for push notifications via Web Push.", + "type": "string" + }, "workspace_proxy": { "type": "boolean" } @@ -10261,6 +10370,14 @@ } } }, + "codersdk.DeleteWebpushSubscription": { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + } + } + }, "codersdk.DeleteWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -10592,19 +10709,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": { @@ -12775,6 +12895,7 @@ "tailnet_coordinator", "template", "user", + "webpush_subscription", "workspace", "workspace_agent_devcontainers", "workspace_agent_resource_monitor", @@ -12812,6 +12933,7 @@ "ResourceTailnetCoordinator", "ResourceTemplate", "ResourceUser", + "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", "ResourceWorkspaceAgentResourceMonitor", @@ -14548,6 +14670,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 3fbbd756eae72..f68ddeadb6e6b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -45,6 +45,7 @@ import ( "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" "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" @@ -260,6 +261,9 @@ type Options struct { AppEncryptionKeyCache cryptokeys.EncryptionKeycache OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock + + // WebPushDispatcher is a way to send notifications over Web Push. + WebPushDispatcher webpush.Dispatcher } // @title Coder API @@ -546,6 +550,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, Experiments: experiments, + WebpushDispatcher: options.WebPushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( ctx, @@ -580,6 +585,7 @@ func New(options *Options) *API { WorkspaceProxy: false, UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), DeploymentID: api.DeploymentID, + WebPushPublicKey: api.WebpushDispatcher.PublicKey(), Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ @@ -1195,6 +1201,11 @@ func New(options *Options) *API { r.Put("/", api.putUserNotificationPreferences) }) }) + r.Route("/webpush", func(r chi.Router) { + r.Post("/subscription", api.postUserWebpushSubscription) + r.Delete("/subscription", api.deleteUserWebpushSubscription) + r.Post("/test", api.postUserPushNotificationTest) + }) }) }) }) @@ -1494,8 +1505,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] + // 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 // for header reasons. WorkspaceProxyHostsFn atomic.Pointer[func() []string] diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6b435157a2e95..ca0bc25e29647 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -78,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" @@ -161,6 +162,7 @@ type Options struct { Logger *slog.Logger StatsBatcher workspacestats.Batcher + WebpushDispatcher webpush.Dispatcher 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.WebpushDispatcher == nil { + // 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 web push notifier: %w", err)) + } + options.WebpushDispatcher = 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, + WebPushDispatcher: options.WebpushDispatcher, 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..87778c66d3dab 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -283,6 +283,8 @@ var ( Site: rbac.Permissions(map[string][]policy.Action{ 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{}, @@ -1176,6 +1178,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, @@ -1381,6 +1390,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 { @@ -2663,6 +2686,20 @@ 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) 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 { @@ -3420,6 +3457,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) @@ -4670,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/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 16414b249ae05..70c2a33443a16 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4531,6 +4531,22 @@ 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("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.GetWebpushVAPIDKeysRow{ + VapidPublicKey: "test", + VapidPrivateKey: "test", + }) + })) + s.Run("UpsertWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertWebpushVAPIDKeysParams{ + VapidPublicKey: "test", + VapidPrivateKey: "test", + }).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestNotifications() { @@ -4568,6 +4584,39 @@ func (s *MethodTestSuite) TestNotifications() { }).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) })) + // 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.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionRead) + })) + s.Run("InsertWebpushSubscription", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) + })) + s.Run("DeleteWebpushSubscriptions", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + }) + check.Args([]uuid.UUID{push.ID}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) + s.Run("DeleteWebpushSubscriptionByUserIDAndEndpoint", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + }) + check.Args(database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ + UserID: user.ID, + Endpoint: push.Endpoint, + }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) + })) + s.Run("DeleteAllWebpushSubscriptions", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceWebpushSubscription, 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..c43bdfba2b8ca 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -479,6 +479,18 @@ func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInbo return notification } +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 webpush 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..46f2de5a5820e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -246,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 @@ -289,6 +290,8 @@ type data struct { lastLicenseID int32 defaultProxyDisplayName string defaultProxyIconURL string + webpushVAPIDPublicKey string + webpushVAPIDPrivateKey string userStatusChanges []database.UserStatusChange telemetryItems []database.TelemetryItem presets []database.TemplateVersionPreset @@ -1853,6 +1856,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() @@ -2422,6 +2433,38 @@ 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 { + 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] + 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 { @@ -6717,6 +6760,34 @@ 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) 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() @@ -9144,6 +9215,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 @@ -12458,6 +12550,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 849de4d2d3dff..05c0418c77acd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -221,6 +221,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) @@ -410,6 +417,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) @@ -1502,6 +1523,20 @@ 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) 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) @@ -2146,6 +2181,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) @@ -3014,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/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 52c26f4c365a6..c5a5db20a9e90 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -318,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() @@ -702,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() @@ -3142,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() @@ -4527,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() @@ -6347,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 caa699ad9c04d..b7908a8880107 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1614,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, @@ -2305,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); @@ -2745,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 95a491b670993..7dab8519a897c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -58,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/000312_webpush_subscriptions.down.sql b/coderd/database/migrations/000312_webpush_subscriptions.down.sql new file mode 100644 index 0000000000000..48cf4168328af --- /dev/null +++ b/coderd/database/migrations/000312_webpush_subscriptions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS webpush_subscriptions; + diff --git a/coderd/database/migrations/000312_webpush_subscriptions.up.sql b/coderd/database/migrations/000312_webpush_subscriptions.up.sql new file mode 100644 index 0000000000000..8319bbb2f5743 --- /dev/null +++ b/coderd/database/migrations/000312_webpush_subscriptions.up.sql @@ -0,0 +1,13 @@ +-- 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 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, + -- 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/migrations/testdata/fixtures/000312_webpush_subscriptions.up.sql b/coderd/database/migrations/testdata/fixtures/000312_webpush_subscriptions.up.sql new file mode 100644 index 0000000000000..4f3e3b0685928 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000312_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 1cf136e364eaa..634cb6b59a41a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3240,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 b12301eac343f..892582c1201e5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -69,6 +69,11 @@ type sqlcQuerier interface { DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error 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. + 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) @@ -104,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. @@ -340,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) @@ -453,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) @@ -597,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 aeeae6591ecc7..221c9f2c51df6 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 deleteAllWebpushSubscriptions = `-- name: DeleteAllWebpushSubscriptions :exec +TRUNCATE TABLE webpush_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) DeleteAllWebpushSubscriptions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllWebpushSubscriptions) + return err +} + const deleteOldNotificationMessages = `-- name: DeleteOldNotificationMessages :exec DELETE FROM notification_messages @@ -4003,6 +4016,31 @@ func (q *sqlQuerier) DeleteOldNotificationMessages(ctx context.Context) error { return err } +const deleteWebpushSubscriptionByUserIDAndEndpoint = `-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec +DELETE FROM webpush_subscriptions +WHERE user_id = $1 AND endpoint = $2 +` + +type DeleteWebpushSubscriptionByUserIDAndEndpointParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Endpoint string `db:"endpoint" json:"endpoint"` +} + +func (q *sqlQuerier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { + _, err := q.db.ExecContext(ctx, deleteWebpushSubscriptionByUserIDAndEndpoint, arg.UserID, arg.Endpoint) + return err +} + +const deleteWebpushSubscriptions = `-- name: DeleteWebpushSubscriptions :exec +DELETE FROM webpush_subscriptions +WHERE id = ANY($1::uuid[]) +` + +func (q *sqlQuerier) DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteWebpushSubscriptions, pq.Array(ids)) + return err +} + const enqueueNotificationMessage = `-- name: EnqueueNotificationMessage :exec INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by, created_at) VALUES ($1, @@ -4255,6 +4293,76 @@ func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID return items, nil } +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 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"` + EndpointP256dhKey string `db:"endpoint_p256dh_key" json:"endpoint_p256dh_key"` + EndpointAuthKey string `db:"endpoint_auth_key" json:"endpoint_auth_key"` +} + +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 WebpushSubscription + 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 @@ -8561,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) ` @@ -8729,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 f2d1a14c3aae7..bf65855925339 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -189,3 +189,28 @@ 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: GetWebpushSubscriptionsByUserID :many +SELECT * +FROM webpush_subscriptions +WHERE user_id = @user_id::uuid; + +-- 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: DeleteWebpushSubscriptions :exec +DELETE FROM webpush_subscriptions +WHERE id = ANY(@ids::uuid[]); + +-- name: DeleteWebpushSubscriptionByUserIDAndEndpoint :exec +DELETE FROM webpush_subscriptions +WHERE user_id = @user_id AND endpoint = @endpoint; + +-- 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 webpush_subscriptions; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index ab9fda7969cea..7ea0e7b001807 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: UpsertWebpushVAPIDKeys :exec +INSERT INTO site_configs (key, value) +VALUES + ('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: 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; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index a30723882a302..9318e1af1678b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -71,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/rbac/object_gen.go b/coderd/rbac/object_gen.go index 0800ab9b25260..f135f262deb97 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -280,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 @@ -367,6 +376,7 @@ func AllResources() []Objecter { ResourceTailnetCoordinator, ResourceTemplate, ResourceUser, + ResourceWebpushSubscription, ResourceWorkspace, ResourceWorkspaceAgentDevcontainers, ResourceWorkspaceAgentResourceMonitor, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 15bebb149f34d..801bbfebf30a5 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"), }, }, + "webpush_subscription": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef("create webpush subscriptions"), + ActionRead: actDef("read webpush subscriptions"), + ActionDelete: actDef("delete webpush subscriptions"), + }, + }, "inbox_notification": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create inbox notifications"), diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index be03ae66eb02a..1080903637ac5 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 webpush notification subscriptions. + { + Name: "WebpushSubscription", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, + 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}, + }, + }, // AnyOrganization tests { Name: "CreateOrgMember", 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 new file mode 100644 index 0000000000000..a6c0790d2dce1 --- /dev/null +++ b/coderd/webpush/webpush.go @@ -0,0 +1,240 @@ +package webpush + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "slices" + "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" +) + +// Dispatcher is an interface that can be used to dispatch +// web push notifications to clients such as browsers. +type Dispatcher interface { + // 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 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 web 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. +// +// See: https://github.com/coder/internal/issues/528 +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) { + return nil, xerrors.Errorf("get notification vapid keys: %w", err) + } + } + if keys.VapidPublicKey == "" || keys.VapidPrivateKey == "" { + // Generate new VAPID keys. This also deletes all existing push + // subscriptions as part of the transaction, as they are no longer + // valid. + newPrivateKey, newPublicKey, err := RegenerateVAPIDKeys(ctx, db) + if err != nil { + return nil, xerrors.Errorf("regenerate vapid keys: %w", err) + } + + keys.VapidPublicKey = newPublicKey + keys.VapidPrivateKey = newPrivateKey + } + + return &Webpusher{ + store: db, + log: log, + VAPIDPublicKey: keys.VapidPublicKey, + VAPIDPrivateKey: keys.VapidPrivateKey, + }, nil +} + +type Webpusher struct { + store database.Store + log *slog.Logger + + VAPIDPublicKey string + VAPIDPrivateKey string +} + +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 web push subscriptions by user ID: %w", err) + } + if len(subscriptions) == 0 { + return nil + } + + msgJSON, err := json.Marshal(msg) + if err != nil { + return xerrors.Errorf("marshal webpush 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 { + // TODO: Implement some retry logic here. For now, this is just a + // best-effort attempt. + statusCode, body, err := n.webpushSend(ctx, msgJSON, subscription.Endpoint, webpush.Keys{ + Auth: subscription.EndpointAuthKey, + P256dh: subscription.EndpointP256dhKey, + }) + if err != nil { + return xerrors.Errorf("send webpush notification: %w", err) + } + + if 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 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 + }) + } + + err = eg.Wait() + if err != nil { + return xerrors.Errorf("send webpush notifications: %w", err) + } + + if len(cleanupSubscriptions) > 0 { + // nolint:gocritic // These are known to be invalid subscriptions. + err = n.store.DeleteWebpushSubscriptions(dbauthz.AsNotifier(ctx), cleanupSubscriptions) + if err != nil { + n.log.Error(ctx, "failed to delete stale push subscriptions", slog.Error(err)) + } + } + + return nil +} + +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 webpush 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 { + msgJSON, err := json.Marshal(codersdk.WebpushMessage{ + Title: "Test", + Body: "This is a test Web Push notification", + }) + if err != nil { + return xerrors.Errorf("marshal webpush notification: %w", err) + } + 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 webpush 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 +} + +// 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 +} + +// NoopWebpusher is a Dispatcher that does nothing except return an error. +// This is returned when web push notifications are disabled, or if there was an +// error generating the VAPID keys. +type NoopWebpusher struct { + Msg string +} + +func (n *NoopWebpusher) Dispatch(context.Context, uuid.UUID, codersdk.WebpushMessage) error { + return xerrors.New(n.Msg) +} + +func (n *NoopWebpusher) Test(context.Context, codersdk.WebpushSubscription) error { + return xerrors.New(n.Msg) +} + +func (*NoopWebpusher) PublicKey() 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.DeleteAllWebpushSubscriptions(ctx); err != nil { + return xerrors.Errorf("delete all webpush subscriptions: %w", err) + } + if err := tx.UpsertWebpushVAPIDKeys(ctx, database.UpsertWebpushVAPIDKeysParams{ + 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/webpush/webpush_test.go b/coderd/webpush/webpush_test.go new file mode 100644 index 0000000000000..2566e0edb348d --- /dev/null +++ b/coderd/webpush/webpush_test.go @@ -0,0 +1,257 @@ +package webpush_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/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/webpush" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +const ( + validEndpointAuthKey = "zqbxT6JKstKSY9JKibZLSQ==" + validEndpointP256dhKey = "BNNL5ZaTfK81qhXOx23+wewhigUeFb632jN6LvRWCFH1ubQr77FE/9qV1FuojuRmHP42zmf34rXgW80OvUVDgTk=" +) + +func TestPush(t *testing.T) { + t.Parallel() + + t.Run("SuccessfulDelivery", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + user := dbgen.User(t, store, database.User{}) + sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.WebpushMessage{ + Title: "Test Title", + Body: "Test Body", + Actions: []codersdk.WebpushMessageAction{ + {Label: "View", URL: "https://coder.com/view"}, + }, + Icon: "workspace", + } + + err = manager.Dispatch(ctx, user.ID, notification) + require.NoError(t, err) + + 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") + }) + + t.Run("ExpiredSubscription", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusGone) + }) + user := dbgen.User(t, store, database.User{}) + _, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.WebpushMessage{ + Title: "Test Title", + Body: "Test Body", + } + + err = manager.Dispatch(ctx, user.ID, notification) + require.NoError(t, err) + + subscriptions, err := store.GetWebpushSubscriptionsByUserID(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() + 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")) + }) + + user := dbgen.User(t, store, database.User{}) + sub, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.WebpushMessage{ + Title: "Test Title", + Body: "Test Body", + } + + err = manager.Dispatch(ctx, user.ID, notification) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid request") + + 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") + }) + + 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(ctx, 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 + user := dbgen.User(t, store, database.User{}) + + sub1, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + Endpoint: serverOKURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + _, err = store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + UserID: user.ID, + Endpoint: serverGoneURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + CreatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + notification := codersdk.WebpushMessage{ + Title: "Test Title", + Body: "Test Body", + Actions: []codersdk.WebpushMessageAction{ + {Label: "View", URL: "https://coder.com/view"}, + }, + } + + err = manager.Dispatch(ctx, user.ID, notification) + 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 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) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + var requestReceived bool + manager, store, serverURL := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + requestReceived = true + w.WriteHeader(http.StatusOK) + }) + + user := dbgen.User(t, store, database.User{}) + + _, err := store.InsertWebpushSubscription(ctx, database.InsertWebpushSubscriptionParams{ + CreatedAt: dbtime.Now(), + UserID: user.ID, + Endpoint: serverURL, + EndpointAuthKey: validEndpointAuthKey, + EndpointP256dhKey: validEndpointP256dhKey, + }) + require.NoError(t, err, "Failed to insert push subscription") + + notification := codersdk.WebpushMessage{ + Title: "Test Notification", + Body: "This is a test notification body", + Actions: []codersdk.WebpushMessageAction{ + {Label: "View Workspace", URL: "https://coder.com/workspace/123"}, + {Label: "Cancel", URL: "https://coder.com/cancel"}, + }, + Icon: "workspace-icon", + } + + err = manager.Dispatch(ctx, user.ID, notification) + 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() + ctx := testutil.Context(t, testutil.WaitShort) + manager, store, _ := setupPushTest(ctx, t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + userID := uuid.New() + notification := codersdk.WebpushMessage{ + Title: "Test Title", + Body: "Test Body", + } + + err := manager.Dispatch(ctx, userID, notification) + require.NoError(t, err) + + subscriptions, err := store.GetWebpushSubscriptionsByUserID(ctx, userID) + require.NoError(t, err) + assert.Empty(t, subscriptions, "No subscriptions should be returned") + }) +} + +// 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 := webpush.New(ctx, &logger, db) + require.NoError(t, err, "Failed to create webpush manager") + + return manager, db, server.URL +} 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") +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5ba0607b4a6d1..dc0bc36a85d5d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2968,6 +2968,7 @@ 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. } return opts @@ -3147,6 +3148,9 @@ type BuildInfoResponse struct { // DeploymentID is the unique identifier for this deployment. DeploymentID string `json:"deployment_id"` + + // WebPushPublicKey is the public key for push notifications via Web Push. + WebPushPublicKey string `json:"webpush_public_key,omitempty"` } type WorkspaceProxyBuildInfo struct { @@ -3189,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/codersdk/notifications.go b/codersdk/notifications.go index ac5fe8e60bce1..9d68c5a01d9c6 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -213,3 +213,70 @@ type UpdateNotificationTemplateMethod struct { type UpdateUserNotificationPreferences struct { TemplateDisabledMap map[string]bool `json:"template_disabled_map"` } + +type WebpushMessageAction struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type WebpushMessage struct { + Icon string `json:"icon"` + Title string `json:"title"` + Body string `json:"body"` + Actions []WebpushMessageAction `json:"actions"` +} + +type WebpushSubscription struct { + Endpoint string `json:"endpoint"` + AuthKey string `json:"auth_key"` + P256DHKey string `json:"p256dh_key"` +} + +type DeleteWebpushSubscription struct { + Endpoint string `json:"endpoint"` +} + +// 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/webpush/subscription", user), req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// 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/webpush/subscription", user), req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +func (c *Client) PostTestWebpushMessage(ctx context.Context) error { + 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.", + }) + 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..7f1bd5da4eb3c 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -34,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" @@ -93,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 25ecf30311478..c016ae5ddc8fe 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -61,6 +61,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "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 e2af6342aabcf..972313001f3ea 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -210,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` | @@ -375,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` | @@ -540,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` | @@ -674,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` | @@ -1030,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/schemas.md b/docs/reference/api/schemas.md index dd6ad218d3617..4fee5c57d5100 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -964,6 +964,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "telemetry": true, "upgrade_message": "string", "version": "string", + "webpush_public_key": "string", "workspace_proxy": true } ``` @@ -980,6 +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 via Web Push. | | `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.DeleteWebpushSubscription + +```json +{ + "endpoint": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|--------|----------|--------------|-------------| +| `endpoint` | string | false | | | + ## codersdk.DeleteWorkspaceAgentPortShareRequest ```json @@ -2804,6 +2820,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `auto-fill-parameters` | | `notifications` | | `workspace-usage` | +| `web-push` | ## codersdk.ExternalAuth @@ -5344,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` | @@ -7470,6 +7488,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/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e8f71dcd781dc..8ad6839c7a635 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -6,13 +6,13 @@ 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. OPTIONS: --allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: 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/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 8442b110ae028..ffb5b541e3a4a 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -157,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", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fe966d7b5ddd2..964c3a16d3365 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 webpush_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 DeleteWebpushSubscription { + readonly endpoint: string; +} + // From codersdk/workspaceagentportshare.go export interface DeleteWorkspaceAgentPortShareRequest { readonly agent_name: string; @@ -745,6 +751,7 @@ export type Experiment = | "auto-fill-parameters" | "example" | "notifications" + | "web-push" | "workspace-usage"; // From codersdk/deployment.go @@ -1973,6 +1980,7 @@ export type RBACResource = | "tailnet_coordinator" | "template" | "user" + | "webpush_subscription" | "*" | "workspace" | "workspace_agent_devcontainers" @@ -2010,6 +2018,7 @@ export const RBACResources: RBACResource[] = [ "tailnet_coordinator", "template", "user", + "webpush_subscription", "*", "workspace", "workspace_agent_devcontainers", @@ -2993,6 +3002,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;