summaryrefslogtreecommitdiff
path: root/internal/webpush
diff options
context:
space:
mode:
Diffstat (limited to 'internal/webpush')
-rw-r--r--internal/webpush/realsender.go341
-rw-r--r--internal/webpush/realsender_test.go263
-rw-r--r--internal/webpush/sender.go54
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 := &notifyingReadCloser{
+ 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: &gtsHTTPClientRoundTripper{
+ httpClient: httpClient,
+ },
+ // Other fields are already set on the http.Client inside the httpclient.Client.
+ },
+ state,
+ converter,
+ )
+}