mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): add webpush package (#17091)
* Adds `codersdk.ExperimentWebPush` (`web-push`) * Adds a `coderd/webpush` package that allows sending native push notifications via `github.com/SherClockHolmes/webpush-go` * Adds database tables to store push notification subscriptions. * Adds an API endpoint that allows users to subscribe/unsubscribe, and send a test notification (404 without experiment, excluded from API docs) * Adds server CLI command to regenerate VAPID keys (note: regenerating the VAPID keypair requires deleting all existing subscriptions) --------- Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
co-authored by
Kyle Carberry
parent
006600ea3e
commit
06e5d9ef21
+23
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+6
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user