[feature] Push notifications (#3587)

* Update push subscription API model to be Mastodon 4.0 compatible

* Add webpush-go dependency

# Conflicts:
#	go.sum

* Single-row table for storing instance's VAPID key pair

* Generate VAPID key pair during startup

* Add VAPID public key to instance info API

* Return VAPID public key when registering an app

* Store Web Push subscriptions in DB

* Add Web Push sender (similar to email sender)

* Add no-op push senders to most processor tests

* Test Web Push notifications from workers

* Delete Web Push subscriptions when account is deleted

* Implement push subscription API

* Linter fixes

* Update Swagger

* Fix enum to int migration

* Fix GetVAPIDKeyPair

* Create web push subscriptions table with indexes

* Log Web Push server error messages

* Send instance URL as Web Push JWT subject

* Accept any 2xx code as a success

* Fix malformed VAPID sub claim

* Use packed notification flags

* Remove unused date columns

* Add notification type for update notifications

Not used yet

* Make GetVAPIDKeyPair idempotent

and remove PutVAPIDKeyPair

* Post-rebase fixes

* go mod tidy

* Special-case 400 errors other than 408/429

Most client errors should remove the subscription.

* Improve titles, trim body to reasonable length

* Disallow cleartext HTTP for Web Push servers

* Fix lint

* Remove redundant index on unique column

Also removes redundant unique and notnull tags on ID column since these are implied by pk

* Make realsender.go more readable

* Use Tobi's style for wrapping errors

* Restore treating all 5xx codes as temporary problems

* Always load target account settings

* Stub `policy` and `standard`

* webpush.Sender: take type converter as ctor param

* Move webpush.MockSender and noopSender into testrig
This commit is contained in:
Vyr Cossont 2025-01-23 16:47:30 -08:00 committed by GitHub
commit 5b765d734e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 21525 additions and 125 deletions

View file

@ -48,6 +48,9 @@ type Application interface {
// GetAllTokens ...
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
// GetTokenByID ...
GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error)
// GetTokenByCode ...
GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error)

View file

@ -174,6 +174,16 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er
return tokens, nil
}
func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) {
return a.getTokenBy(
"ID",
func(t *gtsmodel.Token) error {
return a.db.NewSelect().Model(t).Where("? = ?", bun.Ident("id"), code).Scan(ctx)
},
code,
)
}
func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) {
return a.getTokenBy(
"Code",

View file

@ -88,6 +88,7 @@ type DBService struct {
db.Timeline
db.User
db.Tombstone
db.WebPush
db.WorkerTask
db *bun.DB
}
@ -301,6 +302,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
WebPush: &webPushDB{
db: db,
state: state,
},
WorkerTask: &workerTaskDB{
db: db,
},

View file

@ -149,10 +149,10 @@ func notificationEnumMapping[T ~string]() map[T]new_gtsmodel.NotificationType {
T(old_gtsmodel.NotificationFollowRequest): new_gtsmodel.NotificationFollowRequest,
T(old_gtsmodel.NotificationMention): new_gtsmodel.NotificationMention,
T(old_gtsmodel.NotificationReblog): new_gtsmodel.NotificationReblog,
T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFave,
T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFavourite,
T(old_gtsmodel.NotificationPoll): new_gtsmodel.NotificationPoll,
T(old_gtsmodel.NotificationStatus): new_gtsmodel.NotificationStatus,
T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationSignup,
T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationAdminSignup,
T(old_gtsmodel.NotificationPendingFave): new_gtsmodel.NotificationPendingFave,
T(old_gtsmodel.NotificationPendingReply): new_gtsmodel.NotificationPendingReply,
T(old_gtsmodel.NotificationPendingReblog): new_gtsmodel.NotificationPendingReblog,

View file

@ -0,0 +1,51 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.VAPIDKeyPair{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,61 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.WebPushSubscription{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewCreateIndex().
Model(&gtsmodel.WebPushSubscription{}).
Index("web_push_subscriptions_account_id_idx").
Column("account_id").
IfNotExists().
Exec(ctx); err != nil {
return err
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -66,7 +66,7 @@ func (suite *NotificationTestSuite) spamNotifs() {
notif := &gtsmodel.Notification{
ID: notifID,
NotificationType: gtsmodel.NotificationFave,
NotificationType: gtsmodel.NotificationFavourite,
CreatedAt: time.Now(),
TargetAccountID: targetAccountID,
OriginAccountID: originAccountID,

View file

@ -0,0 +1,270 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
webpushgo "github.com/SherClockHolmes/webpush-go"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
type webPushDB struct {
db *bun.DB
state *state.State
}
func (w *webPushDB) GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) {
var err error
vapidKeyPair, err := w.getVAPIDKeyPair(ctx)
if err != nil {
return nil, err
}
if vapidKeyPair != nil {
return vapidKeyPair, nil
}
// If there aren't any, generate new ones.
vapidKeyPair = &gtsmodel.VAPIDKeyPair{}
if vapidKeyPair.Private, vapidKeyPair.Public, err = webpushgo.GenerateVAPIDKeys(); err != nil {
return nil, gtserror.Newf("error generating VAPID key pair: %w", err)
}
// Store the keys in the database.
if _, err = w.db.NewInsert().
Model(vapidKeyPair).
Exec(ctx); // nocollapse
err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
// Multiple concurrent attempts to generate new keys, and this one didn't win.
// Get the results of the one that did.
return w.getVAPIDKeyPair(ctx)
}
return nil, err
}
// Cache the keys.
w.state.Caches.DB.VAPIDKeyPair.Store(vapidKeyPair)
return vapidKeyPair, nil
}
// getVAPIDKeyPair gets an existing VAPID key pair from cache or DB.
// If there is no existing VAPID key pair, it returns nil, with no error.
func (w *webPushDB) getVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) {
// Look for cached keys.
vapidKeyPair := w.state.Caches.DB.VAPIDKeyPair.Load()
if vapidKeyPair != nil {
return vapidKeyPair, nil
}
// Look for previously generated keys in the database.
vapidKeyPair = &gtsmodel.VAPIDKeyPair{}
if err := w.db.NewSelect().
Model(vapidKeyPair).
Limit(1).
Scan(ctx); // nocollapse
err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, nil
}
return nil, err
}
return vapidKeyPair, nil
}
func (w *webPushDB) DeleteVAPIDKeyPair(ctx context.Context) error {
// Delete any existing keys.
if _, err := w.db.NewTruncateTable().
Model((*gtsmodel.VAPIDKeyPair)(nil)).
Exec(ctx); // nocollapse
err != nil {
return err
}
// Clear the key cache.
w.state.Caches.DB.VAPIDKeyPair.Store(nil)
return nil
}
func (w *webPushDB) GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) {
subscription, err := w.state.Caches.DB.WebPushSubscription.LoadOne(
"TokenID",
func() (*gtsmodel.WebPushSubscription, error) {
var subscription gtsmodel.WebPushSubscription
err := w.db.
NewSelect().
Model(&subscription).
Where("? = ?", bun.Ident("token_id"), tokenID).
Scan(ctx)
return &subscription, err
},
tokenID,
)
if err != nil {
return nil, err
}
return subscription, nil
}
func (w *webPushDB) PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error {
return w.state.Caches.DB.WebPushSubscription.Store(subscription, func() error {
_, err := w.db.NewInsert().
Model(subscription).
Exec(ctx)
return err
})
}
func (w *webPushDB) UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error {
// Update database.
result, err := w.db.
NewUpdate().
Model(subscription).
Column(columns...).
Where("? = ?", bun.Ident("id"), subscription.ID).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return gtserror.Newf("error getting updated row count: %w", err)
}
if rowsAffected == 0 {
return db.ErrNoEntries
}
// Update cache.
w.state.Caches.DB.WebPushSubscription.Put(subscription)
return nil
}
func (w *webPushDB) DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error {
// Deleted partial model for cache invalidation.
var deleted gtsmodel.WebPushSubscription
// Delete subscription, returning subset of columns used by invalidation hook.
if _, err := w.db.NewDelete().
Model(&deleted).
Where("? = ?", bun.Ident("token_id"), tokenID).
Returning("?", bun.Ident("account_id")).
Exec(ctx); // nocollapse
err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate cached subscription by token ID.
w.state.Caches.DB.WebPushSubscription.Invalidate("TokenID", tokenID)
// Call invalidate hook directly.
w.state.Caches.OnInvalidateWebPushSubscription(&deleted)
return nil
}
func (w *webPushDB) GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error) {
// Fetch IDs of all subscriptions created by this account.
subscriptionIDs, err := loadPagedIDs(&w.state.Caches.DB.WebPushSubscriptionIDs, accountID, nil, func() ([]string, error) {
// Subscription IDs not in cache. Perform DB query.
var subscriptionIDs []string
if _, err := w.db.
NewSelect().
Model((*gtsmodel.WebPushSubscription)(nil)).
Column("id").
Where("? = ?", bun.Ident("account_id"), accountID).
Order("id DESC").
Exec(ctx, &subscriptionIDs); // nocollapse
err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, err
}
return subscriptionIDs, nil
})
if err != nil {
return nil, err
}
if len(subscriptionIDs) == 0 {
return nil, nil
}
// Get each subscription by ID from the cache or DB.
subscriptions, err := w.state.Caches.DB.WebPushSubscription.LoadIDs("ID",
subscriptionIDs,
func(uncached []string) ([]*gtsmodel.WebPushSubscription, error) {
subscriptions := make([]*gtsmodel.WebPushSubscription, 0, len(uncached))
if err := w.db.
NewSelect().
Model(&subscriptions).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); // nocollapse
err != nil {
return nil, err
}
return subscriptions, nil
},
)
if err != nil {
return nil, err
}
// Put the subscription structs in the same order as the filter IDs.
xslices.OrderBy(
subscriptions,
subscriptionIDs,
func(subscription *gtsmodel.WebPushSubscription) string {
return subscription.ID
},
)
return subscriptions, nil
}
func (w *webPushDB) DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error {
// Deleted partial models for cache invalidation.
var deleted []*gtsmodel.WebPushSubscription
// Delete subscriptions, returning subset of columns.
if _, err := w.db.NewDelete().
Model(&deleted).
Where("? = ?", bun.Ident("account_id"), accountID).
Returning("?", bun.Ident("account_id")).
Exec(ctx); // nocollapse
err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate cached subscriptions by account ID.
w.state.Caches.DB.WebPushSubscription.Invalidate("AccountID", accountID)
// Call invalidate hooks directly in case those entries weren't cached.
for _, subscription := range deleted {
w.state.Caches.OnInvalidateWebPushSubscription(subscription)
}
return nil
}

View file

@ -0,0 +1,81 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
)
type WebPushTestSuite struct {
BunDBStandardTestSuite
}
// Get the text fixture VAPID key pair.
func (suite *WebPushTestSuite) TestGetVAPIDKeyPair() {
ctx := context.Background()
vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx)
suite.NoError(err)
if !suite.NotNil(vapidKeyPair) {
suite.FailNow("Got a nil VAPID key pair, can't continue")
}
suite.NotEmpty(vapidKeyPair.Private)
suite.NotEmpty(vapidKeyPair.Public)
// Get it again. It should be the same one.
vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx)
suite.NoError(err)
if suite.NotNil(vapidKeyPair2) {
suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private)
suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public)
}
}
// Generate a VAPID key pair when there isn't one.
func (suite *WebPushTestSuite) TestGenerateVAPIDKeyPair() {
ctx := context.Background()
// Delete the text fixture VAPID key pair.
if err := suite.db.DeleteVAPIDKeyPair(ctx); !suite.NoError(err) {
suite.FailNow("Test setup failed: DB error deleting fixture VAPID key pair: %v", err)
}
// Get a new one.
vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx)
suite.NoError(err)
if !suite.NotNil(vapidKeyPair) {
suite.FailNow("Got a nil VAPID key pair, can't continue")
}
suite.NotEmpty(vapidKeyPair.Private)
suite.NotEmpty(vapidKeyPair.Public)
// Get it again. It should be the same one.
vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx)
suite.NoError(err)
if suite.NotNil(vapidKeyPair2) {
suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private)
suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public)
}
}
func TestWebPushTestSuite(t *testing.T) {
suite.Run(t, new(WebPushTestSuite))
}

View file

@ -58,5 +58,6 @@ type DB interface {
Timeline
User
Tombstone
WebPush
WorkerTask
}

54
internal/db/webpush.go Normal file
View file

@ -0,0 +1,54 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package db
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// WebPush contains functions related to Web Push notifications.
type WebPush interface {
// GetVAPIDKeyPair retrieves the server's existing VAPID key pair, if there is one.
// If there isn't one, it generates a new one, stores it, and returns that.
GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error)
// DeleteVAPIDKeyPair deletes the server's VAPID key pair.
DeleteVAPIDKeyPair(ctx context.Context) error
// GetWebPushSubscriptionByTokenID retrieves an access token's Web Push subscription.
// There may not be one, in which case an error will be returned.
GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error)
// PutWebPushSubscription creates an access token's Web Push subscription.
PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error
// UpdateWebPushSubscription updates an access token's Web Push subscription.
// There may not be one, in which case an error will be returned.
UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error
// DeleteWebPushSubscriptionByTokenID deletes an access token's Web Push subscription, if there is one.
DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error
// GetWebPushSubscriptionsByAccountID retrieves an account's list of Web Push subscriptions.
GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error)
// DeleteWebPushSubscriptionsByAccountID deletes an account's list of Web Push subscriptions.
DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error
}