mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 14:42:26 -05:00 
			
		
		
		
	[feature] Implement Web Push notification policy (#3721)
* Web Push: add policy column to subscriptions * Web Push: add policy to API * Web Push: test notification policy * go-fmt unrelated file (how did this get thru?)
This commit is contained in:
		
					parent
					
						
							
								8b74cad422
							
						
					
				
			
			
				commit
				
					
						27844b7da2
					
				
			
		
					 16 changed files with 340 additions and 35 deletions
				
			
		|  | @ -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: | ||||
|  |  | |||
|  | @ -147,6 +147,17 @@ import ( | |||
| //		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: | ||||
|  |  | |||
|  | @ -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, | ||||
| 	) | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import ( | |||
| 	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 @@ import ( | |||
| //		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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 WebPushNotificationPolicy string | |||
| 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" | ||||
| ) | ||||
|  |  | |||
|  | @ -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 <http://www.gnu.org/licenses/>. | ||||
| 
 | ||||
| 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) | ||||
| 	} | ||||
| } | ||||
|  | @ -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 | ||||
| ) | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ import ( | |||
| 	"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 { | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ import ( | |||
| 	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) | ||||
|  |  | |||
|  | @ -231,3 +231,17 @@ func APIInteractionPolicyToInteractionPolicy( | |||
| 		}, | ||||
| 	}, 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 | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 (r *realSender) Send( | |||
| 	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, | ||||
|  |  | |||
|  | @ -23,7 +23,6 @@ import ( | |||
| 	"net/http" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| 
 | ||||
| 	// for go:linkname | ||||
| 	_ "unsafe" | ||||
| 
 | ||||
|  | @ -43,6 +42,7 @@ import ( | |||
| 	"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) { | ||||
|  |  | |||
|  | @ -3610,6 +3610,7 @@ func NewTestWebPushSubscriptions() map[string]*gtsmodel.WebPushSubscription { | |||
| 				gtsmodel.NotificationPendingReply, | ||||
| 				gtsmodel.NotificationPendingReblog, | ||||
| 			}), | ||||
| 			Policy: gtsmodel.WebPushNotificationPolicyFollowed, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue