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:
Cian Johnston
2025-03-27 10:03:53 +00:00
committed by GitHub
co-authored by Kyle Carberry
parent 006600ea3e
commit 06e5d9ef21
43 changed files with 2136 additions and 20 deletions
+145
View File
@@ -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