diff options
Diffstat (limited to 'internal/webpush')
-rw-r--r-- | internal/webpush/realsender.go | 341 | ||||
-rw-r--r-- | internal/webpush/realsender_test.go | 263 | ||||
-rw-r--r-- | internal/webpush/sender.go | 54 |
3 files changed, 658 insertions, 0 deletions
diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go new file mode 100644 index 000000000..8b3a1bd66 --- /dev/null +++ b/internal/webpush/realsender.go @@ -0,0 +1,341 @@ +// 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 webpush + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "time" + + webpushgo "github.com/SherClockHolmes/webpush-go" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/httpclient" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/text" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// realSender is the production Web Push sender, backed by an HTTP client, DB, and worker pool. +type realSender struct { + httpClient *http.Client + state *state.State + converter *typeutils.Converter +} + +// NewRealSender creates a Sender from an http.Client instead of an httpclient.Client. +// This should only be used by NewSender and in tests. +func NewRealSender(httpClient *http.Client, state *state.State, converter *typeutils.Converter) Sender { + return &realSender{ + httpClient: httpClient, + state: state, + converter: converter, + } +} + +func (r *realSender) Send( + ctx context.Context, + notification *gtsmodel.Notification, + filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, +) error { + // Load subscriptions. + subscriptions, err := r.state.DB.GetWebPushSubscriptionsByAccountID(ctx, notification.TargetAccountID) + if err != nil { + return gtserror.Newf( + "error getting Web Push subscriptions for account %s: %w", + notification.TargetAccountID, + err, + ) + } + + // Subscriptions we're actually going to send to. + 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) + }, + ) + if len(relevantSubscriptions) == 0 { + return nil + } + + // Get VAPID keys. + vapidKeyPair, err := r.state.DB.GetVAPIDKeyPair(ctx) + if err != nil { + return gtserror.Newf("error getting VAPID key pair: %w", err) + } + + // Get contact email for this instance, if available. + domain := config.GetHost() + instance, err := r.state.DB.GetInstance(ctx, domain) + if err != nil { + return gtserror.Newf("error getting current instance: %w", err) + } + vapidSubjectEmail := instance.ContactEmail + if vapidSubjectEmail == "" { + // Instance contact email not configured. Use a dummy address. + vapidSubjectEmail = "admin@" + domain + } + + // Get target account settings. + targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID) + if err != nil { + return gtserror.Newf("error getting settings for account %s: %w", notification.TargetAccountID, err) + } + + // Get API representations of notification and accounts involved. + apiNotification, err := r.converter.NotificationToAPINotification(ctx, notification, filters, mutes) + if err != nil { + return gtserror.Newf("error converting notification %s to API representation: %w", notification.ID, err) + } + + // Queue up a .Send() call for each relevant subscription. + for _, subscription := range relevantSubscriptions { + r.state.Workers.WebPush.Queue.Push(func(ctx context.Context) { + if err := r.sendToSubscription( + ctx, + vapidKeyPair, + vapidSubjectEmail, + targetAccountSettings, + subscription, + notification, + apiNotification, + ); err != nil { + log.Errorf( + ctx, + "error sending Web Push notification for subscription with token ID %s: %v", + subscription.TokenID, + err, + ) + } + }) + } + + return nil +} + +// sendToSubscription sends a notification to a single Web Push subscription. +func (r *realSender) sendToSubscription( + ctx context.Context, + vapidKeyPair *gtsmodel.VAPIDKeyPair, + vapidSubjectEmail string, + targetAccountSettings *gtsmodel.AccountSettings, + subscription *gtsmodel.WebPushSubscription, + notification *gtsmodel.Notification, + apiNotification *apimodel.Notification, +) error { + const ( + // TTL is an arbitrary time to ask the Web Push server to store notifications + // while waiting for the client to retrieve them. + TTL = 48 * time.Hour + + // responseBodyMaxLen limits how much of the Web Push server response we read for error messages. + responseBodyMaxLen = 1024 + ) + + // Get the associated access token. + token, err := r.state.DB.GetTokenByID(ctx, subscription.TokenID) + if err != nil { + return gtserror.Newf("error getting token %s: %w", subscription.TokenID, err) + } + + // Create push notification payload struct. + pushNotification := &apimodel.WebPushNotification{ + NotificationID: apiNotification.ID, + NotificationType: apiNotification.Type, + Title: formatNotificationTitle(ctx, subscription, notification, apiNotification), + Body: formatNotificationBody(apiNotification), + Icon: apiNotification.Account.Avatar, + PreferredLocale: targetAccountSettings.Language, + AccessToken: token.Access, + } + + // Encode the push notification as JSON. + pushNotificationBytes, err := json.Marshal(pushNotification) + if err != nil { + return gtserror.Newf("error encoding Web Push notification: %w", err) + } + + // Send push notification. + resp, err := webpushgo.SendNotificationWithContext( + ctx, + pushNotificationBytes, + &webpushgo.Subscription{ + Endpoint: subscription.Endpoint, + Keys: webpushgo.Keys{ + Auth: subscription.Auth, + P256dh: subscription.P256dh, + }, + }, + &webpushgo.Options{ + HTTPClient: r.httpClient, + Subscriber: vapidSubjectEmail, + VAPIDPublicKey: vapidKeyPair.Public, + VAPIDPrivateKey: vapidKeyPair.Private, + TTL: int(TTL.Seconds()), + }, + ) + if err != nil { + return gtserror.Newf("error sending Web Push notification: %w", err) + } + defer resp.Body.Close() + + switch { + // All good, delivered. + case resp.StatusCode >= 200 && resp.StatusCode <= 299: + return nil + + // Temporary outage or some other delivery issue. + case resp.StatusCode == http.StatusRequestTimeout || + resp.StatusCode == http.StatusRequestEntityTooLarge || + resp.StatusCode == http.StatusTooManyRequests || + (resp.StatusCode >= 500 && resp.StatusCode <= 599): + + // Try to get the response body. + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, responseBodyMaxLen)) + if err != nil { + return gtserror.Newf("error reading Web Push server response: %w", err) + } + + // Return the error with its response body. + return gtserror.Newf( + "unexpected HTTP status %s received when sending Web Push notification: %s", + resp.Status, + string(bodyBytes), + ) + + // Some serious error that indicates auth problems, not a Web Push server, etc. + // We should not send any more notifications to this subscription. Try to delete it. + default: + err := r.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, subscription.TokenID) + if err != nil { + return gtserror.Newf( + "received HTTP status %s but failed to delete subscription: %s", + resp.Status, + err, + ) + } + + log.Infof( + ctx, + "Deleted Web Push subscription with token ID %s because push server sent HTTP status %s", + subscription.TokenID, resp.Status, + ) + return nil + } +} + +// formatNotificationTitle creates a title for a Web Push notification from the notification type and account's name. +func formatNotificationTitle( + ctx context.Context, + subscription *gtsmodel.WebPushSubscription, + notification *gtsmodel.Notification, + apiNotification *apimodel.Notification, +) string { + displayNameOrAcct := apiNotification.Account.DisplayName + if displayNameOrAcct == "" { + displayNameOrAcct = apiNotification.Account.Acct + } + + switch notification.NotificationType { + case gtsmodel.NotificationFollow: + return fmt.Sprintf("%s followed you", displayNameOrAcct) + case gtsmodel.NotificationFollowRequest: + return fmt.Sprintf("%s requested to follow you", displayNameOrAcct) + case gtsmodel.NotificationMention: + return fmt.Sprintf("%s mentioned you", displayNameOrAcct) + case gtsmodel.NotificationReblog: + return fmt.Sprintf("%s boosted your post", displayNameOrAcct) + case gtsmodel.NotificationFavourite: + return fmt.Sprintf("%s faved your post", displayNameOrAcct) + case gtsmodel.NotificationPoll: + if subscription.AccountID == notification.TargetAccountID { + return "Your poll has ended" + } else { + return fmt.Sprintf("%s's poll has ended", displayNameOrAcct) + } + case gtsmodel.NotificationStatus: + return fmt.Sprintf("%s posted", displayNameOrAcct) + case gtsmodel.NotificationAdminSignup: + return fmt.Sprintf("%s requested to sign up", displayNameOrAcct) + case gtsmodel.NotificationPendingFave: + return fmt.Sprintf("%s faved your post, which requires your approval", displayNameOrAcct) + case gtsmodel.NotificationPendingReply: + return fmt.Sprintf("%s mentioned you, which requires your approval", displayNameOrAcct) + case gtsmodel.NotificationPendingReblog: + return fmt.Sprintf("%s boosted your post, which requires your approval", displayNameOrAcct) + case gtsmodel.NotificationAdminReport: + return fmt.Sprintf("%s submitted a report", displayNameOrAcct) + case gtsmodel.NotificationUpdate: + return fmt.Sprintf("%s updated their post", displayNameOrAcct) + default: + log.Warnf(ctx, "Unknown notification type: %d", notification.NotificationType) + return fmt.Sprintf( + "%s did something (unknown notification type %d)", + displayNameOrAcct, + notification.NotificationType, + ) + } +} + +// formatNotificationBody creates a body for a Web Push notification, +// from the CW or beginning of the body text of the status, if there is one, +// or the beginning of the bio text of the related account. +func formatNotificationBody(apiNotification *apimodel.Notification) string { + // bodyMaxLen is a polite maximum length for a Web Push notification's body text, in bytes. Note that this isn't + // limited per se, but Web Push servers may reject anything with a total request body size over 4k. + const bodyMaxLen = 3000 + + var body string + if apiNotification.Status != nil { + if apiNotification.Status.SpoilerText != "" { + body = apiNotification.Status.SpoilerText + } else { + body = text.SanitizeToPlaintext(apiNotification.Status.Content) + } + } else { + body = text.SanitizeToPlaintext(apiNotification.Account.Note) + } + return firstNBytesTrimSpace(body, bodyMaxLen) +} + +// firstNBytesTrimSpace returns the first N bytes of a string, trimming leading and trailing whitespace. +func firstNBytesTrimSpace(s string, n int) string { + return strings.TrimSpace(text.FirstNBytesByWords(strings.TrimSpace(s), n)) +} + +// gtsHTTPClientRoundTripper helps wrap a GtS HTTP client back into a regular HTTP client, +// so that webpush-go can use our IP filters, bad hosts list, and retries. +type gtsHTTPClientRoundTripper struct { + httpClient *httpclient.Client +} + +func (r *gtsHTTPClientRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return r.httpClient.Do(request) +} diff --git a/internal/webpush/realsender_test.go b/internal/webpush/realsender_test.go new file mode 100644 index 000000000..c94bbbb8e --- /dev/null +++ b/internal/webpush/realsender_test.go @@ -0,0 +1,263 @@ +// 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 webpush_test + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/cleaner" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/subscriptions" + "github.com/superseriousbusiness/gotosocial/internal/transport" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/webpush" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type RealSenderStandardTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + state state.State + mediaManager *media.Manager + typeconverter *typeutils.Converter + httpClient *testrig.MockHTTPClient + transportController transport.Controller + federator *federation.Federator + oauthServer oauth.Server + emailSender email.Sender + 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 + + processor *processing.Processor + + webPushHttpClientDo func(request *http.Request) (*http.Response, error) +} + +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() +} + +func (suite *RealSenderStandardTestSuite) SetupTest() { + suite.state.Caches.Init() + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + suite.typeconverter = typeutils.NewConverter(&suite.state) + + testrig.StartTimelines( + &suite.state, + visibility.NewFilter(&suite.state), + suite.typeconverter, + ) + + suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media") + suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople() + suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() + + suite.transportController = testrig.NewTestTransportController(&suite.state, suite.httpClient) + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) + + suite.webPushSender = webpush.NewRealSender( + &http.Client{ + Transport: suite, + }, + &suite.state, + suite.typeconverter, + ) + + suite.processor = processing.NewProcessor( + cleaner.New(&suite.state), + subscriptions.New( + &suite.state, + suite.transportController, + suite.typeconverter, + ), + suite.typeconverter, + suite.federator, + suite.oauthServer, + suite.mediaManager, + &suite.state, + suite.emailSender, + suite.webPushSender, + visibility.NewFilter(&suite.state), + interaction.NewFilter(&suite.state), + ) + testrig.StartWorkers(&suite.state, suite.processor.Workers()) + + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../testrig/media") +} + +func (suite *RealSenderStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) + suite.webPushHttpClientDo = nil +} + +// RoundTrip implements http.RoundTripper with a closure stored in the test suite. +func (suite *RealSenderStandardTestSuite) RoundTrip(request *http.Request) (*http.Response, error) { + return suite.webPushHttpClientDo(request) +} + +// notifyingReadCloser is a zero-length io.ReadCloser that can tell us when it's been closed, +// indicating the simulated Web Push server response has been sent, received, read, and closed. +type notifyingReadCloser struct { + bodyClosed chan struct{} +} + +func (rc *notifyingReadCloser) Read(_ []byte) (n int, err error) { + return 0, io.EOF +} + +func (rc *notifyingReadCloser) Close() error { + rc.bodyClosed <- struct{}{} + close(rc.bodyClosed) + return nil +} + +// Simulate sending a push notification with the suite's fake web client. +func (suite *RealSenderStandardTestSuite) simulatePushNotification( + statusCode int, + 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) + defer cancel() + + notification, err := suite.state.DB.GetNotificationByID(ctx, suite.testNotifications["local_account_1_like"].ID) + if !suite.NoError(err) { + suite.FailNow("Couldn't fetch notification to send") + } + + rc := ¬ifyingReadCloser{ + bodyClosed: make(chan struct{}, 1), + } + + // Simulate a response from the Web Push server. + suite.webPushHttpClientDo = func(request *http.Request) (*http.Response, error) { + return &http.Response{ + Status: http.StatusText(statusCode), + StatusCode: statusCode, + Body: rc, + }, nil + } + + // Send the push notification. + sendError := suite.webPushSender.Send(ctx, notification, nil, nil) + + // Wait for it to be sent or for the context to time out. + bodyClosed := false + contextExpired := false + select { + case <-rc.bodyClosed: + bodyClosed = true + case <-ctx.Done(): + contextExpired = true + } + suite.True(bodyClosed) + suite.False(contextExpired) + + // Look for the associated Web Push subscription. Some server responses should delete it. + subscription, err := suite.state.DB.GetWebPushSubscriptionByTokenID( + ctx, + suite.testWebPushSubscriptions["local_account_1_token_1"].TokenID, + ) + if expectDeletedSubscription { + suite.ErrorIs(err, db.ErrNoEntries) + } else { + suite.NotNil(subscription) + } + + return sendError +} + +// Test a successful response to sending a push notification. +func (suite *RealSenderStandardTestSuite) TestSendSuccess() { + suite.NoError(suite.simulatePushNotification(http.StatusOK, 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)) +} + +// 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)) +} + +// 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)) +} + +func TestRealSenderStandardTestSuite(t *testing.T) { + suite.Run(t, &RealSenderStandardTestSuite{}) +} diff --git a/internal/webpush/sender.go b/internal/webpush/sender.go new file mode 100644 index 000000000..5331f049a --- /dev/null +++ b/internal/webpush/sender.go @@ -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 webpush + +import ( + "context" + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/httpclient" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Sender can send Web Push notifications. +type Sender interface { + // Send queues up a notification for delivery to all of an account's Web Push subscriptions. + Send( + ctx context.Context, + notification *gtsmodel.Notification, + filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, + ) error +} + +// NewSender creates a new sender from an HTTP client, DB, and worker pool. +func NewSender(httpClient *httpclient.Client, state *state.State, converter *typeutils.Converter) Sender { + return NewRealSender( + &http.Client{ + Transport: >sHTTPClientRoundTripper{ + httpClient: httpClient, + }, + // Other fields are already set on the http.Client inside the httpclient.Client. + }, + state, + converter, + ) +} |