Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 06e5d9e

Browse files
johnstcnkylecarbs
andauthored
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 <[email protected]>
1 parent 006600e commit 06e5d9e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2136
-20
lines changed

cli/server.go

+23-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import (
6464
"github.com/coder/coder/v2/coderd/entitlements"
6565
"github.com/coder/coder/v2/coderd/notifications/reports"
6666
"github.com/coder/coder/v2/coderd/runtimeconfig"
67+
"github.com/coder/coder/v2/coderd/webpush"
6768

6869
"github.com/coder/coder/v2/buildinfo"
6970
"github.com/coder/coder/v2/cli/clilog"
@@ -775,6 +776,26 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
775776
return xerrors.Errorf("set deployment id: %w", err)
776777
}
777778

779+
// Manage push notifications.
780+
experiments := coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value())
781+
if experiments.Enabled(codersdk.ExperimentWebPush) {
782+
webpusher, err := webpush.New(ctx, &options.Logger, options.Database)
783+
if err != nil {
784+
options.Logger.Error(ctx, "failed to create web push dispatcher", slog.Error(err))
785+
options.Logger.Warn(ctx, "web push notifications will not work until the VAPID keys are regenerated")
786+
webpusher = &webpush.NoopWebpusher{
787+
Msg: "Web Push notifications are disabled due to a system error. Please contact your Coder administrator.",
788+
}
789+
}
790+
options.WebPushDispatcher = webpusher
791+
} else {
792+
options.WebPushDispatcher = &webpush.NoopWebpusher{
793+
// Users will likely not see this message as the endpoints return 404
794+
// if not enabled. Just in case...
795+
Msg: "Web Push notifications are an experimental feature and are disabled by default. Enable the 'web-push' experiment to use this feature.",
796+
}
797+
}
798+
778799
githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals)
779800
if err != nil {
780801
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.
12551276
}
12561277

12571278
createAdminUserCmd := r.newCreateAdminUserCommand()
1279+
regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand()
12581280

12591281
rawURLOpt := serpent.Option{
12601282
Flag: "raw-url",
@@ -1268,7 +1290,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
12681290

12691291
serverCmd.Children = append(
12701292
serverCmd.Children,
1271-
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd,
1293+
createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd,
12721294
)
12731295

12741296
return serverCmd
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//go:build !slim
2+
3+
package cli
4+
5+
import (
6+
"fmt"
7+
8+
"golang.org/x/xerrors"
9+
10+
"cdr.dev/slog"
11+
"cdr.dev/slog/sloggers/sloghuman"
12+
13+
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/coderd/database"
15+
"github.com/coder/coder/v2/coderd/database/awsiamrds"
16+
"github.com/coder/coder/v2/coderd/webpush"
17+
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/serpent"
19+
)
20+
21+
func (r *RootCmd) newRegenerateVapidKeypairCommand() *serpent.Command {
22+
var (
23+
regenVapidKeypairDBURL string
24+
regenVapidKeypairPgAuth string
25+
)
26+
regenerateVapidKeypairCommand := &serpent.Command{
27+
Use: "regenerate-vapid-keypair",
28+
Short: "Regenerate the VAPID keypair used for web push notifications.",
29+
Hidden: true, // Hide this command as it's an experimental feature
30+
Handler: func(inv *serpent.Invocation) error {
31+
var (
32+
ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...)
33+
cfg = r.createConfig()
34+
logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr))
35+
)
36+
if r.verbose {
37+
logger = logger.Leveled(slog.LevelDebug)
38+
}
39+
40+
defer cancel()
41+
42+
if regenVapidKeypairDBURL == "" {
43+
cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath())
44+
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger, "")
45+
if err != nil {
46+
return err
47+
}
48+
defer func() {
49+
_ = closePg()
50+
}()
51+
regenVapidKeypairDBURL = url
52+
}
53+
54+
sqlDriver := "postgres"
55+
var err error
56+
if codersdk.PostgresAuth(regenVapidKeypairPgAuth) == codersdk.PostgresAuthAWSIAMRDS {
57+
sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver)
58+
if err != nil {
59+
return xerrors.Errorf("register aws rds iam auth: %w", err)
60+
}
61+
}
62+
63+
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, regenVapidKeypairDBURL, nil)
64+
if err != nil {
65+
return xerrors.Errorf("connect to postgres: %w", err)
66+
}
67+
defer func() {
68+
_ = sqlDB.Close()
69+
}()
70+
db := database.New(sqlDB)
71+
72+
// Confirm that the user really wants to regenerate the VAPID keypair.
73+
cliui.Infof(inv.Stdout, "Regenerating VAPID keypair...")
74+
cliui.Infof(inv.Stdout, "This will delete all existing webpush subscriptions.")
75+
cliui.Infof(inv.Stdout, "Are you sure you want to continue? (y/N)")
76+
77+
if resp, err := cliui.Prompt(inv, cliui.PromptOptions{
78+
IsConfirm: true,
79+
Default: cliui.ConfirmNo,
80+
}); err != nil || resp != cliui.ConfirmYes {
81+
return xerrors.Errorf("VAPID keypair regeneration failed: %w", err)
82+
}
83+
84+
if _, _, err := webpush.RegenerateVAPIDKeys(ctx, db); err != nil {
85+
return xerrors.Errorf("regenerate vapid keypair: %w", err)
86+
}
87+
88+
_, _ = fmt.Fprintln(inv.Stdout, "VAPID keypair regenerated successfully.")
89+
return nil
90+
},
91+
}
92+
93+
regenerateVapidKeypairCommand.Options.Add(
94+
cliui.SkipPromptOption(),
95+
serpent.Option{
96+
Env: "CODER_PG_CONNECTION_URL",
97+
Flag: "postgres-url",
98+
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).",
99+
Value: serpent.StringOf(&regenVapidKeypairDBURL),
100+
},
101+
serpent.Option{
102+
Name: "Postgres Connection Auth",
103+
Description: "Type of auth to use when connecting to postgres.",
104+
Flag: "postgres-connection-auth",
105+
Env: "CODER_PG_CONNECTION_AUTH",
106+
Default: "password",
107+
Value: serpent.EnumOf(&regenVapidKeypairPgAuth, codersdk.PostgresAuthDrivers...),
108+
},
109+
)
110+
111+
return regenerateVapidKeypairCommand
112+
}
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package cli_test
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/v2/cli/clitest"
11+
"github.com/coder/coder/v2/coderd/database"
12+
"github.com/coder/coder/v2/coderd/database/dbgen"
13+
"github.com/coder/coder/v2/coderd/database/dbtestutil"
14+
"github.com/coder/coder/v2/pty/ptytest"
15+
"github.com/coder/coder/v2/testutil"
16+
)
17+
18+
func TestRegenerateVapidKeypair(t *testing.T) {
19+
t.Parallel()
20+
if !dbtestutil.WillUsePostgres() {
21+
t.Skip("this test is only supported on postgres")
22+
}
23+
24+
t.Run("NoExistingVAPIDKeys", func(t *testing.T) {
25+
t.Parallel()
26+
27+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
28+
t.Cleanup(cancel)
29+
30+
connectionURL, err := dbtestutil.Open(t)
31+
require.NoError(t, err)
32+
33+
sqlDB, err := sql.Open("postgres", connectionURL)
34+
require.NoError(t, err)
35+
defer sqlDB.Close()
36+
37+
db := database.New(sqlDB)
38+
// Ensure there is no existing VAPID keypair.
39+
rows, err := db.GetWebpushVAPIDKeys(ctx)
40+
require.NoError(t, err)
41+
require.Empty(t, rows)
42+
43+
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
44+
45+
pty := ptytest.New(t)
46+
inv.Stdout = pty.Output()
47+
inv.Stderr = pty.Output()
48+
clitest.Start(t, inv)
49+
50+
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
51+
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
52+
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
53+
pty.WriteLine("y")
54+
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
55+
56+
// Ensure the VAPID keypair was created.
57+
keys, err := db.GetWebpushVAPIDKeys(ctx)
58+
require.NoError(t, err)
59+
require.NotEmpty(t, keys.VapidPublicKey)
60+
require.NotEmpty(t, keys.VapidPrivateKey)
61+
})
62+
63+
t.Run("ExistingVAPIDKeys", func(t *testing.T) {
64+
t.Parallel()
65+
66+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
67+
t.Cleanup(cancel)
68+
69+
connectionURL, err := dbtestutil.Open(t)
70+
require.NoError(t, err)
71+
72+
sqlDB, err := sql.Open("postgres", connectionURL)
73+
require.NoError(t, err)
74+
defer sqlDB.Close()
75+
76+
db := database.New(sqlDB)
77+
for i := 0; i < 10; i++ {
78+
// Insert a few fake users.
79+
u := dbgen.User(t, db, database.User{})
80+
// Insert a few fake push subscriptions for each user.
81+
for j := 0; j < 10; j++ {
82+
_ = dbgen.WebpushSubscription(t, db, database.InsertWebpushSubscriptionParams{
83+
UserID: u.ID,
84+
})
85+
}
86+
}
87+
88+
inv, _ := clitest.New(t, "server", "regenerate-vapid-keypair", "--postgres-url", connectionURL, "--yes")
89+
90+
pty := ptytest.New(t)
91+
inv.Stdout = pty.Output()
92+
inv.Stderr = pty.Output()
93+
clitest.Start(t, inv)
94+
95+
pty.ExpectMatchContext(ctx, "Regenerating VAPID keypair...")
96+
pty.ExpectMatchContext(ctx, "This will delete all existing webpush subscriptions.")
97+
pty.ExpectMatchContext(ctx, "Are you sure you want to continue? (y/N)")
98+
pty.WriteLine("y")
99+
pty.ExpectMatchContext(ctx, "VAPID keypair regenerated successfully.")
100+
101+
// Ensure the VAPID keypair was created.
102+
keys, err := db.GetWebpushVAPIDKeys(ctx)
103+
require.NoError(t, err)
104+
require.NotEmpty(t, keys.VapidPublicKey)
105+
require.NotEmpty(t, keys.VapidPrivateKey)
106+
107+
// Ensure the push subscriptions were deleted.
108+
var count int64
109+
rows, err := sqlDB.QueryContext(ctx, "SELECT COUNT(*) FROM webpush_subscriptions")
110+
require.NoError(t, err)
111+
t.Cleanup(func() {
112+
_ = rows.Close()
113+
})
114+
require.True(t, rows.Next())
115+
require.NoError(t, rows.Scan(&count))
116+
require.Equal(t, int64(0), count)
117+
})
118+
}

cli/testdata/coder_server_--help.golden

+6-6
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ USAGE:
66
Start a Coder server
77

88
SUBCOMMANDS:
9-
create-admin-user Create a new admin user with the given username,
10-
email and password and adds it to every
11-
organization.
12-
postgres-builtin-serve Run the built-in PostgreSQL deployment.
13-
postgres-builtin-url Output the connection URL for the built-in
14-
PostgreSQL deployment.
9+
create-admin-user Create a new admin user with the given username,
10+
email and password and adds it to every
11+
organization.
12+
postgres-builtin-serve Run the built-in PostgreSQL deployment.
13+
postgres-builtin-url Output the connection URL for the built-in
14+
PostgreSQL deployment.
1515

1616
OPTIONS:
1717
--allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false)

0 commit comments

Comments
 (0)