diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 5ff5346bc..65d332227 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -9922,6 +9922,16 @@ paths: in: formData name: data[alerts][pending.reblog] type: boolean + - default: all + description: Which accounts to receive push notifications from. + enum: + - all + - followed + - follower + - none + in: formData + name: data[policy] + type: string produces: - application/json responses: @@ -10019,6 +10029,16 @@ paths: in: formData name: data[alerts][pending.reblog] type: boolean + - default: all + description: Which accounts to receive push notifications from. + enum: + - all + - followed + - follower + - none + in: formData + name: data[policy] + type: string produces: - application/json responses: diff --git a/internal/api/client/push/pushsubscriptionpost.go b/internal/api/client/push/pushsubscriptionpost.go index a7e299894..cc1be185f 100644 --- a/internal/api/client/push/pushsubscriptionpost.go +++ b/internal/api/client/push/pushsubscriptionpost.go @@ -147,6 +147,17 @@ // type: boolean // default: false // description: Receive a push notification when a boost is pending? +// - +// name: data[policy] +// in: formData +// type: string +// enum: +// - all +// - followed +// - follower +// - none +// default: all +// description: Which accounts to receive push notifications from. // // security: // - OAuth2 Bearer: diff --git a/internal/api/client/push/pushsubscriptionpost_test.go b/internal/api/client/push/pushsubscriptionpost_test.go index bdd22d729..e7e8582df 100644 --- a/internal/api/client/push/pushsubscriptionpost_test.go +++ b/internal/api/client/push/pushsubscriptionpost_test.go @@ -44,6 +44,7 @@ func (suite *PushTestSuite) postSubscription( p256dh *string, alertsMention *bool, alertsStatus *bool, + policy *string, requestJson *string, expectedHTTPStatus int, ) (*apimodel.WebPushSubscription, error) { @@ -80,6 +81,9 @@ func (suite *PushTestSuite) postSubscription( if alertsStatus != nil { ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)} } + if policy != nil { + ctx.Request.Form["data[policy]"] = []string{*policy} + } } // trigger the handler @@ -119,6 +123,7 @@ func (suite *PushTestSuite) TestPostSubscription() { p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" alertsMention := true alertsStatus := false + policy := "followed" subscription, err := suite.postSubscription( accountFixtureName, tokenFixtureName, @@ -127,6 +132,7 @@ func (suite *PushTestSuite) TestPostSubscription() { &p256dh, &alertsMention, &alertsStatus, + &policy, nil, 200, ) @@ -138,6 +144,7 @@ func (suite *PushTestSuite) TestPostSubscription() { suite.False(subscription.Alerts.Status) // Omitted event types should default to off. suite.False(subscription.Alerts.Favourite) + suite.Equal(apimodel.WebPushNotificationPolicyFollowed, subscription.Policy) } } @@ -159,6 +166,7 @@ func (suite *PushTestSuite) TestPostSubscriptionMinimal() { nil, nil, nil, + nil, 200, ) if suite.NoError(err) { @@ -169,6 +177,8 @@ func (suite *PushTestSuite) TestPostSubscriptionMinimal() { suite.False(subscription.Alerts.Mention) suite.False(subscription.Alerts.Status) suite.False(subscription.Alerts.Favourite) + // Policy should default to all. + suite.Equal(apimodel.WebPushNotificationPolicyAll, subscription.Policy) } } @@ -192,6 +202,7 @@ func (suite *PushTestSuite) TestPostInvalidSubscription() { &alertsMention, &alertsStatus, nil, + nil, 422, ) suite.NoError(err) @@ -215,7 +226,8 @@ func (suite *PushTestSuite) TestPostSubscriptionJSON() { "alerts": { "mention": true, "status": false - } + }, + "policy": "followed" } }` subscription, err := suite.postSubscription( @@ -226,6 +238,7 @@ func (suite *PushTestSuite) TestPostSubscriptionJSON() { nil, nil, nil, + nil, &requestJson, 200, ) @@ -237,6 +250,7 @@ func (suite *PushTestSuite) TestPostSubscriptionJSON() { suite.False(subscription.Alerts.Status) // Omitted event types should default to off. suite.False(subscription.Alerts.Favourite) + suite.Equal(apimodel.WebPushNotificationPolicyFollowed, subscription.Policy) } } @@ -263,6 +277,7 @@ func (suite *PushTestSuite) TestPostSubscriptionJSONMinimal() { nil, nil, nil, + nil, &requestJson, 200, ) @@ -274,6 +289,8 @@ func (suite *PushTestSuite) TestPostSubscriptionJSONMinimal() { suite.False(subscription.Alerts.Mention) suite.False(subscription.Alerts.Status) suite.False(subscription.Alerts.Favourite) + // Policy should default to all. + suite.Equal(apimodel.WebPushNotificationPolicyAll, subscription.Policy) } } @@ -306,6 +323,7 @@ func (suite *PushTestSuite) TestPostInvalidSubscriptionJSON() { nil, nil, nil, + nil, &requestJson, 422, ) @@ -323,6 +341,7 @@ func (suite *PushTestSuite) TestPostExistingSubscription() { p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" alertsMention := true alertsStatus := false + policy := "followed" subscription, err := suite.postSubscription( accountFixtureName, tokenFixtureName, @@ -331,6 +350,7 @@ func (suite *PushTestSuite) TestPostExistingSubscription() { &p256dh, &alertsMention, &alertsStatus, + &policy, nil, 200, ) diff --git a/internal/api/client/push/pushsubscriptionput.go b/internal/api/client/push/pushsubscriptionput.go index 06575f4ee..4d1c5765e 100644 --- a/internal/api/client/push/pushsubscriptionput.go +++ b/internal/api/client/push/pushsubscriptionput.go @@ -25,6 +25,7 @@ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // PushSubscriptionPUTHandler swagger:operation PUT /api/v1/push/subscription pushSubscriptionPut @@ -122,6 +123,17 @@ // type: boolean // default: false // description: Receive a push notification when a boost is pending? +// - +// name: data[policy] +// in: formData +// type: string +// enum: +// - all +// - followed +// - follower +// - none +// default: all +// description: Which accounts to receive push notifications from. // // security: // - OAuth2 Bearer: @@ -181,7 +193,8 @@ func (m *Module) PushSubscriptionPUTHandler(c *gin.Context) { apiutil.JSON(c, http.StatusOK, apiSubscription) } -// validateNormalizeUpdate copies form fields to their canonical JSON equivalents. +// validateNormalizeUpdate copies form fields to their canonical JSON equivalents +// and sets defaults for fields that have them. func validateNormalizeUpdate(request *apimodel.WebPushSubscriptionUpdateRequest) error { if request.Data == nil { request.Data = &apimodel.WebPushSubscriptionRequestData{} @@ -228,5 +241,12 @@ func validateNormalizeUpdate(request *apimodel.WebPushSubscriptionUpdateRequest) request.Data.Alerts.Reblog = *request.DataAlertsPendingReblog } + if request.DataPolicy != nil { + request.Data.Policy = request.DataPolicy + } + if request.Data.Policy == nil { + request.Data.Policy = util.Ptr(apimodel.WebPushNotificationPolicyAll) + } + return nil } diff --git a/internal/api/client/push/pushsubscriptionput_test.go b/internal/api/client/push/pushsubscriptionput_test.go index 924e3d475..d9f0e395e 100644 --- a/internal/api/client/push/pushsubscriptionput_test.go +++ b/internal/api/client/push/pushsubscriptionput_test.go @@ -41,6 +41,7 @@ func (suite *PushTestSuite) putSubscription( tokenFixtureName string, alertsMention *bool, alertsStatus *bool, + policy *string, requestJson *string, expectedHTTPStatus int, ) (*apimodel.WebPushSubscription, error) { @@ -68,6 +69,9 @@ func (suite *PushTestSuite) putSubscription( if alertsStatus != nil { ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)} } + if policy != nil { + ctx.Request.Form["data[policy]"] = []string{*policy} + } } // trigger the handler @@ -104,11 +108,13 @@ func (suite *PushTestSuite) TestPutSubscription() { alertsMention := true alertsStatus := false + policy := "followed" subscription, err := suite.putSubscription( accountFixtureName, tokenFixtureName, &alertsMention, &alertsStatus, + &policy, nil, 200, ) @@ -120,6 +126,7 @@ func (suite *PushTestSuite) TestPutSubscription() { suite.False(subscription.Alerts.Status) // Omitted event types should default to off. suite.False(subscription.Alerts.Favourite) + suite.Equal(apimodel.WebPushNotificationPolicyFollowed, subscription.Policy) } } @@ -134,7 +141,8 @@ func (suite *PushTestSuite) TestPutSubscriptionJSON() { "alerts": { "mention": true, "status": false - } + }, + "policy": "followed" } }` subscription, err := suite.putSubscription( @@ -142,6 +150,7 @@ func (suite *PushTestSuite) TestPutSubscriptionJSON() { tokenFixtureName, nil, nil, + nil, &requestJson, 200, ) @@ -153,6 +162,7 @@ func (suite *PushTestSuite) TestPutSubscriptionJSON() { suite.False(subscription.Alerts.Status) // Omitted event types should default to off. suite.False(subscription.Alerts.Favourite) + suite.Equal(apimodel.WebPushNotificationPolicyFollowed, subscription.Policy) } } @@ -170,6 +180,7 @@ func (suite *PushTestSuite) TestPutMissingSubscription() { &alertsMention, &alertsStatus, nil, + nil, 404, ) suite.NoError(err) diff --git a/internal/api/model/webpushsubscription.go b/internal/api/model/webpushsubscription.go index a28bb7294..38d6cf7ed 100644 --- a/internal/api/model/webpushsubscription.go +++ b/internal/api/model/webpushsubscription.go @@ -138,6 +138,8 @@ type WebPushSubscriptionUpdateRequest struct { DataAlertsPendingFavourite *bool `form:"data[alerts][pending.favourite]" json:"-"` DataAlertsPendingReply *bool `form:"data[alerts][pending.reply]" json:"-"` DataAlertsPendingReblog *bool `form:"data[alerts][pending.reblog]" json:"-"` + + DataPolicy *WebPushNotificationPolicy `form:"data[policy]" json:"-"` } // WebPushSubscriptionRequestData is the part of a Web Push subscription that can be changed after creation. @@ -146,6 +148,9 @@ type WebPushSubscriptionUpdateRequest struct { type WebPushSubscriptionRequestData struct { // Alerts selects the specific events that this Web Push subscription will receive. Alerts *WebPushSubscriptionAlerts `form:"-" json:"alerts"` + + // Policy selects which accounts will trigger Web Push notifications. + Policy *WebPushNotificationPolicy `form:"-" json:"policy"` } // WebPushNotificationPolicy names sets of accounts that can generate notifications. @@ -154,4 +159,10 @@ type WebPushSubscriptionRequestData struct { const ( // WebPushNotificationPolicyAll allows all accounts to send notifications to the subscribing user. WebPushNotificationPolicyAll WebPushNotificationPolicy = "all" + // WebPushNotificationPolicyFollowed allows accounts followed by the subscribing user to send notifications. + WebPushNotificationPolicyFollowed WebPushNotificationPolicy = "followed" + // WebPushNotificationPolicyFollower allows accounts following the subscribing user to send notifications. + WebPushNotificationPolicyFollower WebPushNotificationPolicy = "follower" + // WebPushNotificationPolicyNone doesn't allow any acounts to send notifications to the subscribing user. + WebPushNotificationPolicyNone WebPushNotificationPolicy = "none" ) diff --git a/internal/db/bundb/migrations/20250131184755_add_web_push_subscription_policy.go b/internal/db/bundb/migrations/20250131184755_add_web_push_subscription_policy.go new file mode 100644 index 000000000..d833a669f --- /dev/null +++ b/internal/db/bundb/migrations/20250131184755_add_web_push_subscription_policy.go @@ -0,0 +1,83 @@ +// 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 . + +package migrations + +import ( + "context" + "reflect" + "strings" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + model := >smodel.WebPushSubscription{} + + // Get the column definition for the new policy column. + modelType := reflect.TypeOf(model) + columnDef, err := getBunColumnDef(tx, modelType, "Policy") + if err != nil { + return err + } + + // Add the policy column. + switch tx.Dialect().Name() { + case dialect.SQLite: + // Doesn't support Bun feature AlterColumnExists. + if _, err = tx. + NewAddColumn(). + Model(model). + ColumnExpr(columnDef). + Exec(ctx); // nocollapse + err != nil && !strings.Contains(err.Error(), "duplicate column name") { + // Return errors that aren't about this column already existing. + return err + } + + case dialect.PG: + // Supports Bun feature AlterColumnExists. + if _, err = tx. + NewAddColumn(). + Model(model). + ColumnExpr(columnDef). + IfNotExists(). + Exec(ctx); // nocollapse + err != nil { + return err + } + + default: + panic("unsupported db type") + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return nil + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/gtsmodel/webpushsubscription.go b/internal/gtsmodel/webpushsubscription.go index 4aeef654a..1e310bc50 100644 --- a/internal/gtsmodel/webpushsubscription.go +++ b/internal/gtsmodel/webpushsubscription.go @@ -39,12 +39,15 @@ type WebPushSubscription struct { // P256dh is a Base64-encoded Diffie-Hellman public key on the P-256 elliptic curve. P256dh string `bun:",nullzero,notnull"` - // NotificationFlags controls which notifications are delivered to a given subscription. - // Corresponds to model.PushSubscriptionAlerts. + // NotificationFlags controls which notifications are delivered to this subscription. NotificationFlags WebPushSubscriptionNotificationFlags `bun:",notnull"` + + // Policy controls which accounts are allowed to trigger notifications for this subscription. + Policy WebPushNotificationPolicy `bun:",nullzero,notnull,default:1"` } // WebPushSubscriptionNotificationFlags is a bitfield representation of a set of NotificationType. +// Corresponds to apimodel.WebPushSubscriptionAlerts. type WebPushSubscriptionNotificationFlags int64 // WebPushSubscriptionNotificationFlagsFromSlice packs a slice of NotificationType into a WebPushSubscriptionNotificationFlags. @@ -80,3 +83,18 @@ func (n *WebPushSubscriptionNotificationFlags) Set(notificationType Notification *n &= ^(1 << notificationType) } } + +// WebPushNotificationPolicy represents the notification policy of a Web Push subscription. +// Corresponds to apimodel.WebPushNotificationPolicy. +type WebPushNotificationPolicy enumType + +const ( + // WebPushNotificationPolicyAll allows all accounts to send notifications to the subscribing user. + WebPushNotificationPolicyAll WebPushNotificationPolicy = 1 + // WebPushNotificationPolicyFollowed allows accounts followed by the subscribing user to send notifications. + WebPushNotificationPolicyFollowed WebPushNotificationPolicy = 2 + // WebPushNotificationPolicyFollower allows accounts following the subscribing user to send notifications. + WebPushNotificationPolicyFollower WebPushNotificationPolicy = 3 + // WebPushNotificationPolicyNone doesn't allow any accounts to send notifications to the subscribing user. + WebPushNotificationPolicyNone WebPushNotificationPolicy = 4 +) diff --git a/internal/processing/push/create.go b/internal/processing/push/create.go index 42a67dc19..dc15ccf12 100644 --- a/internal/processing/push/create.go +++ b/internal/processing/push/create.go @@ -24,6 +24,7 @@ "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) // CreateOrReplace creates a Web Push subscription for the given access token, @@ -54,6 +55,7 @@ func (p *Processor) CreateOrReplace( Auth: request.Subscription.Keys.Auth, P256dh: request.Subscription.Keys.P256dh, NotificationFlags: alertsToNotificationFlags(request.Data.Alerts), + Policy: typeutils.APIWebPushNotificationPolicyToWebPushNotificationPolicy(*request.Data.Policy), } if err := p.state.DB.PutWebPushSubscription(ctx, subscription); err != nil { diff --git a/internal/processing/push/update.go b/internal/processing/push/update.go index 370536f9b..94529455a 100644 --- a/internal/processing/push/update.go +++ b/internal/processing/push/update.go @@ -24,6 +24,7 @@ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) // Update updates the Web Push subscription for the given access token. @@ -50,10 +51,13 @@ func (p *Processor) Update( // Update it. subscription.NotificationFlags = alertsToNotificationFlags(request.Data.Alerts) + subscription.Policy = typeutils.APIWebPushNotificationPolicyToWebPushNotificationPolicy(*request.Data.Policy) + if err = p.state.DB.UpdateWebPushSubscription( ctx, subscription, "notification_flags", + "policy", ); err != nil { err := gtserror.Newf("couldn't update Web Push subscription for token ID %s: %w", tokenID, err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go index 82957ee05..b341aa6ae 100644 --- a/internal/typeutils/frontendtointernal.go +++ b/internal/typeutils/frontendtointernal.go @@ -231,3 +231,17 @@ func(uri gtsmodel.PolicyValue) bool { }, }, nil } + +func APIWebPushNotificationPolicyToWebPushNotificationPolicy(policy apimodel.WebPushNotificationPolicy) gtsmodel.WebPushNotificationPolicy { + switch policy { + case apimodel.WebPushNotificationPolicyAll: + return gtsmodel.WebPushNotificationPolicyAll + case apimodel.WebPushNotificationPolicyFollowed: + return gtsmodel.WebPushNotificationPolicyFollowed + case apimodel.WebPushNotificationPolicyFollower: + return gtsmodel.WebPushNotificationPolicyFollower + case apimodel.WebPushNotificationPolicyNone: + return gtsmodel.WebPushNotificationPolicyNone + } + return 0 +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index ce949d577..9eeaa3c0d 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -1235,9 +1235,9 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() { req := >smodel.InteractionRequest{ ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE", CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), - StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3", + StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3", Status: >smodel.Status{URI: "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3"}, - TargetAccountID: acceptingAccount.ID, + TargetAccountID: acceptingAccount.ID, TargetAccount: acceptingAccount, InteractingAccountID: interactingAccount.ID, InteractingAccount: interactingAccount, diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 487e8434e..d966c054c 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -3019,6 +3019,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq( }, nil } +func webPushNotificationPolicyToAPIWebPushNotificationPolicy(policy gtsmodel.WebPushNotificationPolicy) apimodel.WebPushNotificationPolicy { + switch policy { + case gtsmodel.WebPushNotificationPolicyAll: + return apimodel.WebPushNotificationPolicyAll + case gtsmodel.WebPushNotificationPolicyFollowed: + return apimodel.WebPushNotificationPolicyFollowed + case gtsmodel.WebPushNotificationPolicyFollower: + return apimodel.WebPushNotificationPolicyFollower + case gtsmodel.WebPushNotificationPolicyNone: + return apimodel.WebPushNotificationPolicyNone + } + return "" +} + func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription( ctx context.Context, subscription *gtsmodel.WebPushSubscription, @@ -3047,7 +3061,7 @@ func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription( PendingReply: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReply), PendingReblog: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReblog), }, - Policy: apimodel.WebPushNotificationPolicyAll, + Policy: webPushNotificationPolicyToAPIWebPushNotificationPolicy(subscription.Policy), Standard: true, }, nil } diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go index 4faf57fb2..919cea788 100644 --- a/internal/webpush/realsender.go +++ b/internal/webpush/realsender.go @@ -67,8 +67,7 @@ func (r *realSender) Send( relevantSubscriptions := slices.DeleteFunc( subscriptions, func(subscription *gtsmodel.WebPushSubscription) bool { - // Remove subscriptions that don't want this type of notification. - return !subscription.NotificationFlags.Get(notification.NotificationType) + return r.shouldSkipSubscription(ctx, notification, subscription) }, ) if len(relevantSubscriptions) == 0 { @@ -117,6 +116,68 @@ func(subscription *gtsmodel.WebPushSubscription) bool { return nil } +// shouldSkipSubscription returns true if this subscription is not relevant to this notification. +func (r *realSender) shouldSkipSubscription( + ctx context.Context, + notification *gtsmodel.Notification, + subscription *gtsmodel.WebPushSubscription, +) bool { + // Remove subscriptions that don't want this type of notification. + if !subscription.NotificationFlags.Get(notification.NotificationType) { + return true + } + + // Check against subscription's notification policy. + switch subscription.Policy { + case gtsmodel.WebPushNotificationPolicyAll: + // Allow notifications from any account. + return false + + case gtsmodel.WebPushNotificationPolicyFollowed: + // Allow if the subscription account follows the notifying account. + isFollowing, err := r.state.DB.IsFollowing(ctx, subscription.AccountID, notification.OriginAccountID) + if err != nil { + log.Errorf( + ctx, + "error checking whether account %s follows account %s: %v", + subscription.AccountID, + notification.OriginAccountID, + err, + ) + return true + } + return !isFollowing + + case gtsmodel.WebPushNotificationPolicyFollower: + // Allow if the notifying account follows the subscription account. + isFollowing, err := r.state.DB.IsFollowing(ctx, notification.OriginAccountID, subscription.AccountID) + if err != nil { + log.Errorf( + ctx, + "error checking whether account %s follows account %s: %v", + notification.OriginAccountID, + subscription.AccountID, + err, + ) + return true + } + return !isFollowing + + case gtsmodel.WebPushNotificationPolicyNone: + // This subscription doesn't want any push notifications. + return true + + default: + log.Errorf( + ctx, + "unknown Web Push notification policy for subscription with token ID %s: %d", + subscription.TokenID, + subscription.Policy, + ) + return true + } +} + // sendToSubscription sends a notification to a single Web Push subscription. func (r *realSender) sendToSubscription( ctx context.Context, diff --git a/internal/webpush/realsender_test.go b/internal/webpush/realsender_test.go index 8446fc47d..d5172c00e 100644 --- a/internal/webpush/realsender_test.go +++ b/internal/webpush/realsender_test.go @@ -23,7 +23,6 @@ "net/http" "testing" "time" - // for go:linkname _ "unsafe" @@ -43,6 +42,7 @@ "github.com/superseriousbusiness/gotosocial/internal/subscriptions" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/webpush" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -62,16 +62,7 @@ type RealSenderStandardTestSuite struct { webPushSender webpush.Sender // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention - testEmojis map[string]*gtsmodel.Emoji testNotifications map[string]*gtsmodel.Notification testWebPushSubscriptions map[string]*gtsmodel.WebPushSubscription @@ -81,16 +72,7 @@ type RealSenderStandardTestSuite struct { } func (suite *RealSenderStandardTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() - suite.testTags = testrig.NewTestTags() - suite.testMentions = testrig.NewTestMentions() - suite.testEmojis = testrig.NewTestEmojis() suite.testNotifications = testrig.NewTestNotifications() suite.testWebPushSubscriptions = testrig.NewTestWebPushSubscriptions() } @@ -184,14 +166,16 @@ func (rc *notifyingReadCloser) Close() error { // Simulate sending a push notification with the suite's fake web client. func (suite *RealSenderStandardTestSuite) simulatePushNotification( + notificationID string, statusCode int, + expectSend bool, expectDeletedSubscription bool, ) error { // Don't let the test run forever if the push notification was not sent for some reason. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - notification, err := suite.state.DB.GetNotificationByID(ctx, suite.testNotifications["local_account_1_like"].ID) + notification, err := suite.state.DB.GetNotificationByID(ctx, notificationID) if !suite.NoError(err) { suite.FailNow("Couldn't fetch notification to send") } @@ -221,6 +205,14 @@ func (suite *RealSenderStandardTestSuite) simulatePushNotification( case <-ctx.Done(): contextExpired = true } + + // In some cases we expect the notification *not* to be sent. + if !expectSend { + suite.False(bodyClosed) + suite.True(contextExpired) + return nil + } + suite.True(bodyClosed) suite.False(contextExpired) @@ -240,25 +232,48 @@ func (suite *RealSenderStandardTestSuite) simulatePushNotification( // Test a successful response to sending a push notification. func (suite *RealSenderStandardTestSuite) TestSendSuccess() { - suite.NoError(suite.simulatePushNotification(http.StatusOK, false)) + notificationID := suite.testNotifications["local_account_1_like"].ID + suite.NoError(suite.simulatePushNotification(notificationID, http.StatusOK, true, false)) } // Test a rate-limiting response to sending a push notification. // This should not delete the subscription. func (suite *RealSenderStandardTestSuite) TestRateLimited() { - suite.NoError(suite.simulatePushNotification(http.StatusTooManyRequests, false)) + notificationID := suite.testNotifications["local_account_1_like"].ID + suite.NoError(suite.simulatePushNotification(notificationID, http.StatusTooManyRequests, true, false)) } // Test a non-special-cased client error response to sending a push notification. // This should delete the subscription. func (suite *RealSenderStandardTestSuite) TestClientError() { - suite.NoError(suite.simulatePushNotification(http.StatusBadRequest, true)) + notificationID := suite.testNotifications["local_account_1_like"].ID + suite.NoError(suite.simulatePushNotification(notificationID, http.StatusBadRequest, true, true)) } // Test a server error response to sending a push notification. // This should not delete the subscription. func (suite *RealSenderStandardTestSuite) TestServerError() { - suite.NoError(suite.simulatePushNotification(http.StatusInternalServerError, false)) + notificationID := suite.testNotifications["local_account_1_like"].ID + suite.NoError(suite.simulatePushNotification(notificationID, http.StatusInternalServerError, true, false)) +} + +// Don't send a push notification if it doesn't match policy. +func (suite *RealSenderStandardTestSuite) TestSendPolicyMismatch() { + // Setup: create a new notification from an account that the subscribed account doesn't follow. + notification := >smodel.Notification{ + ID: "01JJZ2Y9Z8E1XKT90EHZ5KZBDW", + NotificationType: gtsmodel.NotificationFavourite, + TargetAccountID: suite.testAccounts["local_account_1"].ID, + OriginAccountID: suite.testAccounts["remote_account_1"].ID, + StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY", + Read: util.Ptr(false), + } + if err := suite.db.PutNotification(context.Background(), notification); !suite.NoError(err) { + suite.FailNow(err.Error()) + return + } + + suite.NoError(suite.simulatePushNotification(notification.ID, 0, false, false)) } func TestRealSenderStandardTestSuite(t *testing.T) { diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 9a54aba70..806e64891 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -3610,6 +3610,7 @@ func NewTestWebPushSubscriptions() map[string]*gtsmodel.WebPushSubscription { gtsmodel.NotificationPendingReply, gtsmodel.NotificationPendingReblog, }), + Policy: gtsmodel.WebPushNotificationPolicyFollowed, }, } }