mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-10-29 06:22:25 -05:00
[feature] Push notifications (#3587)
* Update push subscription API model to be Mastodon 4.0 compatible * Add webpush-go dependency # Conflicts: # go.sum * Single-row table for storing instance's VAPID key pair * Generate VAPID key pair during startup * Add VAPID public key to instance info API * Return VAPID public key when registering an app * Store Web Push subscriptions in DB * Add Web Push sender (similar to email sender) * Add no-op push senders to most processor tests * Test Web Push notifications from workers * Delete Web Push subscriptions when account is deleted * Implement push subscription API * Linter fixes * Update Swagger * Fix enum to int migration * Fix GetVAPIDKeyPair * Create web push subscriptions table with indexes * Log Web Push server error messages * Send instance URL as Web Push JWT subject * Accept any 2xx code as a success * Fix malformed VAPID sub claim * Use packed notification flags * Remove unused date columns * Add notification type for update notifications Not used yet * Make GetVAPIDKeyPair idempotent and remove PutVAPIDKeyPair * Post-rebase fixes * go mod tidy * Special-case 400 errors other than 408/429 Most client errors should remove the subscription. * Improve titles, trim body to reasonable length * Disallow cleartext HTTP for Web Push servers * Fix lint * Remove redundant index on unique column Also removes redundant unique and notnull tags on ID column since these are implied by pk * Make realsender.go more readable * Use Tobi's style for wrapping errors * Restore treating all 5xx codes as temporary problems * Always load target account settings * Stub `policy` and `standard` * webpush.Sender: take type converter as ctor param * Move webpush.MockSender and noopSender into testrig
This commit is contained in:
parent
9333bbc4d0
commit
5b765d734e
134 changed files with 21525 additions and 125 deletions
341
internal/webpush/realsender.go
Normal file
341
internal/webpush/realsender.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
263
internal/webpush/realsender_test.go
Normal file
263
internal/webpush/realsender_test.go
Normal file
|
|
@ -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{})
|
||||
}
|
||||
54
internal/webpush/sender.go
Normal file
54
internal/webpush/sender.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package 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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue