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
parent 006600ea3e
commit 06e5d9ef21
43 changed files with 2136 additions and 20 deletions

View File

@ -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 {