summaryrefslogtreecommitdiff
path: root/internal/webpush/realsender.go
diff options
context:
space:
mode:
authorLibravatar Vyr Cossont <VyrCossont@users.noreply.github.com>2025-01-23 16:47:30 -0800
committerLibravatar GitHub <noreply@github.com>2025-01-23 16:47:30 -0800
commit5b765d734ee70f0a8a0790444d60969a727567f8 (patch)
treef76e05a6e5b22df17160be595c40e964bdbe5f22 /internal/webpush/realsender.go
parent[feature] Serve bot accounts over AP as Service instead of Person (#3672) (diff)
downloadgotosocial-5b765d734ee70f0a8a0790444d60969a727567f8.tar.xz
[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
Diffstat (limited to 'internal/webpush/realsender.go')
-rw-r--r--internal/webpush/realsender.go341
1 files changed, 341 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)
+}