diff options
author | 2025-01-23 16:47:30 -0800 | |
---|---|---|
committer | 2025-01-23 16:47:30 -0800 | |
commit | 5b765d734ee70f0a8a0790444d60969a727567f8 (patch) | |
tree | f76e05a6e5b22df17160be595c40e964bdbe5f22 /internal | |
parent | [feature] Serve bot accounts over AP as Service instead of Person (#3672) (diff) | |
download | gotosocial-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')
92 files changed, 4003 insertions, 107 deletions
diff --git a/internal/api/activitypub/emoji/emojiget_test.go b/internal/api/activitypub/emoji/emojiget_test.go index 0eb1f5931..7d3587fd8 100644 --- a/internal/api/activitypub/emoji/emojiget_test.go +++ b/internal/api/activitypub/emoji/emojiget_test.go @@ -88,7 +88,13 @@ func (suite *EmojiGetTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.emojiModule = emoji.New(suite.processor) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/activitypub/users/user_test.go b/internal/api/activitypub/users/user_test.go index 10eb33937..d66fe8cf9 100644 --- a/internal/api/activitypub/users/user_test.go +++ b/internal/api/activitypub/users/user_test.go @@ -100,7 +100,13 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) testrig.StartWorkers(&suite.state, suite.processor.Workers()) suite.userModule = users.New(suite.processor) diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go index c5ceba387..cfbdec7ec 100644 --- a/internal/api/auth/auth_test.go +++ b/internal/api/auth/auth_test.go @@ -91,7 +91,13 @@ func (suite *AuthStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.authModule = auth.New(suite.db, suite.processor, suite.idp) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/api/client.go b/internal/api/client.go index 60daddf87..3112aeea5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -47,6 +47,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/notifications" "github.com/superseriousbusiness/gotosocial/internal/api/client/polls" "github.com/superseriousbusiness/gotosocial/internal/api/client/preferences" + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" "github.com/superseriousbusiness/gotosocial/internal/api/client/reports" "github.com/superseriousbusiness/gotosocial/internal/api/client/search" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" @@ -91,6 +92,7 @@ type Client struct { notifications *notifications.Module // api/v1/notifications polls *polls.Module // api/v1/polls preferences *preferences.Module // api/v1/preferences + push *push.Module // api/v1/push reports *reports.Module // api/v1/reports search *search.Module // api/v1/search, api/v2/search statuses *statuses.Module // api/v1/statuses @@ -143,6 +145,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.notifications.Route(h) c.polls.Route(h) c.preferences.Route(h) + c.push.Route(h) c.reports.Route(h) c.search.Route(h) c.statuses.Route(h) @@ -183,6 +186,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { notifications: notifications.New(p), polls: polls.New(p), preferences: preferences.New(p), + push: push.New(p), reports: reports.New(p), search: search.New(p), statuses: statuses.New(p), diff --git a/internal/api/client/accounts/account_test.go b/internal/api/client/accounts/account_test.go index db212af22..e700ade78 100644 --- a/internal/api/client/accounts/account_test.go +++ b/internal/api/client/accounts/account_test.go @@ -100,7 +100,13 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.accountsModule = accounts.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go index 479f16f45..f44d48d78 100644 --- a/internal/api/client/admin/admin_test.go +++ b/internal/api/client/admin/admin_test.go @@ -106,7 +106,13 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.adminModule = admin.New(&suite.state, suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go index 43c1eeee4..a11597f7c 100644 --- a/internal/api/client/bookmarks/bookmarks_test.go +++ b/internal/api/client/bookmarks/bookmarks_test.go @@ -114,7 +114,13 @@ func (suite *BookmarkTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.statusModule = statuses.New(suite.processor) suite.bookmarkModule = bookmarks.New(suite.processor) } diff --git a/internal/api/client/exports/exports_test.go b/internal/api/client/exports/exports_test.go index 13f7bea05..55d873348 100644 --- a/internal/api/client/exports/exports_test.go +++ b/internal/api/client/exports/exports_test.go @@ -95,6 +95,7 @@ func (suite *ExportsTestSuite) SetupTest() { &suite.state, federator, testrig.NewEmailSender("../../../../web/template/", nil), + testrig.NewNoopWebPushSender(), mediaManager, ) diff --git a/internal/api/client/favourites/favourites_test.go b/internal/api/client/favourites/favourites_test.go index dcdc8fee2..7cfa205e3 100644 --- a/internal/api/client/favourites/favourites_test.go +++ b/internal/api/client/favourites/favourites_test.go @@ -98,7 +98,13 @@ func (suite *FavouritesStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.favModule = favourites.New(suite.processor) } diff --git a/internal/api/client/filters/v1/filter_test.go b/internal/api/client/filters/v1/filter_test.go index 128426435..558f3d959 100644 --- a/internal/api/client/filters/v1/filter_test.go +++ b/internal/api/client/filters/v1/filter_test.go @@ -105,7 +105,13 @@ func (suite *FiltersTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.filtersModule = filtersV1.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/api/client/filters/v2/filter_test.go b/internal/api/client/filters/v2/filter_test.go index 20411c090..8301c67ad 100644 --- a/internal/api/client/filters/v2/filter_test.go +++ b/internal/api/client/filters/v2/filter_test.go @@ -103,9 +103,14 @@ func (suite *FiltersTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager) - suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.filtersModule = filtersV2.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/api/client/followedtags/followedtags_test.go b/internal/api/client/followedtags/followedtags_test.go index 89a61aca1..816e1d0cc 100644 --- a/internal/api/client/followedtags/followedtags_test.go +++ b/internal/api/client/followedtags/followedtags_test.go @@ -88,7 +88,13 @@ func (suite *FollowedTagsTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.followedTagsModule = followedtags.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/api/client/followrequests/followrequest_test.go b/internal/api/client/followrequests/followrequest_test.go index 1faac2bbc..787d47c84 100644 --- a/internal/api/client/followrequests/followrequest_test.go +++ b/internal/api/client/followrequests/followrequest_test.go @@ -96,7 +96,13 @@ func (suite *FollowRequestStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.followRequestModule = followrequests.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/import/import_test.go b/internal/api/client/import/import_test.go index 5129f862e..fba83e1a3 100644 --- a/internal/api/client/import/import_test.go +++ b/internal/api/client/import/import_test.go @@ -92,6 +92,7 @@ func (suite *ImportTestSuite) SetupTest() { &suite.state, federator, testrig.NewEmailSender("../../../../web/template/", nil), + testrig.NewNoopWebPushSender(), mediaManager, ) testrig.StartWorkers(&suite.state, processor.Workers()) diff --git a/internal/api/client/instance/instance_test.go b/internal/api/client/instance/instance_test.go index 293c96020..f0427369b 100644 --- a/internal/api/client/instance/instance_test.go +++ b/internal/api/client/instance/instance_test.go @@ -99,7 +99,13 @@ func (suite *InstanceStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.instanceModule = instance.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/lists/lists_test.go b/internal/api/client/lists/lists_test.go index ea5adbb5c..5fd2304c7 100644 --- a/internal/api/client/lists/lists_test.go +++ b/internal/api/client/lists/lists_test.go @@ -99,7 +99,13 @@ func (suite *ListsStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.listsModule = lists.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 2eec8341f..d26f2bb7a 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -104,7 +104,13 @@ func (suite *MediaCreateTestSuite) SetupTest() { suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) // setup module being tested suite.mediaModule = mediamodule.New(suite.processor) diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index c3a1fb340..dd115f465 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -102,7 +102,13 @@ func (suite *MediaUpdateTestSuite) SetupTest() { suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) // setup module being tested suite.mediaModule = mediamodule.New(suite.processor) diff --git a/internal/api/client/mutes/mutes_test.go b/internal/api/client/mutes/mutes_test.go index b721b080f..3f5686cfb 100644 --- a/internal/api/client/mutes/mutes_test.go +++ b/internal/api/client/mutes/mutes_test.go @@ -96,7 +96,13 @@ func (suite *MutesTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.mutesModule = mutes.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/notifications/notifications_test.go b/internal/api/client/notifications/notifications_test.go index 39fb66691..5794c0e12 100644 --- a/internal/api/client/notifications/notifications_test.go +++ b/internal/api/client/notifications/notifications_test.go @@ -100,7 +100,13 @@ func (suite *NotificationsTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.notificationsModule = notifications.New(suite.processor) } diff --git a/internal/api/client/polls/polls_test.go b/internal/api/client/polls/polls_test.go index b5552f39e..8c2bc8ba1 100644 --- a/internal/api/client/polls/polls_test.go +++ b/internal/api/client/polls/polls_test.go @@ -36,14 +36,15 @@ import ( type PollsStandardTestSuite struct { suite.Suite - db db.DB - storage *storage.Driver - mediaManager *media.Manager - federator *federation.Federator - processor *processing.Processor - emailSender email.Sender - sentEmails map[string]string - state state.State + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + webPushSender *testrig.WebPushMockSender + state state.State // standard suite models testTokens map[string]*gtsmodel.Token @@ -91,7 +92,13 @@ func (suite *PollsStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.pollsModule = polls.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/push/push.go b/internal/api/client/push/push.go new file mode 100644 index 000000000..33b974efa --- /dev/null +++ b/internal/api/client/push/push.go @@ -0,0 +1,49 @@ +// 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 push + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base path for serving the push API, minus the 'api' prefix. + BasePath = "/v1/push" + // SubscriptionPath is the path for serving requests for the current auth token's push subscription. + SubscriptionPath = BasePath + "/subscription" +) + +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, SubscriptionPath, m.PushSubscriptionGETHandler) + attachHandler(http.MethodPost, SubscriptionPath, m.PushSubscriptionPOSTHandler) + attachHandler(http.MethodPut, SubscriptionPath, m.PushSubscriptionPUTHandler) + attachHandler(http.MethodDelete, SubscriptionPath, m.PushSubscriptionDELETEHandler) +} diff --git a/internal/api/client/push/push_test.go b/internal/api/client/push/push_test.go new file mode 100644 index 000000000..0d85192ff --- /dev/null +++ b/internal/api/client/push/push_test.go @@ -0,0 +1,110 @@ +// 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 push_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type PushTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + state state.State + + // 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 + testWebPushSubscriptions map[string]*gtsmodel.WebPushSubscription + + // module being tested + pushModule *push.Module +} + +func (suite *PushTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testWebPushSubscriptions = testrig.NewTestWebPushSubscriptions() +} + +func (suite *PushTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartNoopWorkers(&suite.state) + + testrig.InitTestConfig() + config.Config(func(cfg *config.Configuration) { + cfg.WebAssetBaseDir = "../../../../web/assets/" + cfg.WebTemplateBaseDir = "../../../../web/templates/" + }) + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) + suite.pushModule = push.New(suite.processor) + + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *PushTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +func TestPushTestSuite(t *testing.T) { + suite.Run(t, new(PushTestSuite)) +} diff --git a/internal/api/client/push/pushsubscriptiondelete.go b/internal/api/client/push/pushsubscriptiondelete.go new file mode 100644 index 000000000..2a5fd8e69 --- /dev/null +++ b/internal/api/client/push/pushsubscriptiondelete.go @@ -0,0 +1,64 @@ +// 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 push + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PushSubscriptionDELETEHandler swagger:operation DELETE /api/v1/push/subscription pushSubscriptionDelete +// +// Delete the Web Push subscription associated with the current auth token. +// If there is no subscription, returns successfully anyway. +// +// --- +// tags: +// - push +// +// security: +// - OAuth2 Bearer: +// - push +// +// responses: +// '200': +// description: Push subscription deleted, or did not exist. +// '400': +// description: bad request +// '401': +// description: unauthorized +// '500': +// description: internal server error +func (m *Module) PushSubscriptionDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if errWithCode := m.processor.Push().Delete(c.Request.Context(), authed.Token.GetAccess()); errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/push/pushsubscriptiondelete_test.go b/internal/api/client/push/pushsubscriptiondelete_test.go new file mode 100644 index 000000000..3e81ce2a1 --- /dev/null +++ b/internal/api/client/push/pushsubscriptiondelete_test.go @@ -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 push_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +// deleteSubscription deletes the push subscription for the named account and token. +func (suite *PushTestSuite) deleteSubscription( + accountFixtureName string, + tokenFixtureName string, + expectedHTTPStatus int, +) error { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath + ctx.Request = httptest.NewRequest(http.MethodDelete, requestUrl, nil) + + // trigger the handler + suite.pushModule.PushSubscriptionDELETEHandler(ctx) + + // read the response + result := recorder.Result() + defer func() { + _ = result.Body.Close() + }() + + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + return fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + return nil +} + +// Delete a subscription that should exist. +func (suite *PushTestSuite) TestDeleteSubscription() { + accountFixtureName := "local_account_1" + // This token should have a subscription associated with it already. + tokenFixtureName := "local_account_1" + + err := suite.deleteSubscription(accountFixtureName, tokenFixtureName, 200) + suite.NoError(err) +} + +// Delete a subscription that should not exist, which should succeed anyway. +func (suite *PushTestSuite) TestDeleteMissingSubscription() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + err := suite.deleteSubscription(accountFixtureName, tokenFixtureName, 200) + suite.NoError(err) +} diff --git a/internal/api/client/push/pushsubscriptionget.go b/internal/api/client/push/pushsubscriptionget.go new file mode 100644 index 000000000..10774b862 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionget.go @@ -0,0 +1,71 @@ +// 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 push + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PushSubscriptionGETHandler swagger:operation GET /api/v1/push/subscription pushSubscriptionGet +// +// Get the push subscription for the current access token. +// +// --- +// tags: +// - push +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - push +// +// responses: +// '200': +// description: Web Push subscription for current access token. +// schema: +// "$ref": "#/definitions/webPushSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: This access token doesn't have an associated subscription. +// '500': +// description: internal server error +func (m *Module) PushSubscriptionGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiSubscription, errWithCode := m.processor.Push().Get(c, authed.Token.GetAccess()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiSubscription) +} diff --git a/internal/api/client/push/pushsubscriptionget_test.go b/internal/api/client/push/pushsubscriptionget_test.go new file mode 100644 index 000000000..23fb9e7f2 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionget_test.go @@ -0,0 +1,102 @@ +// 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 push_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +// getSubscription gets the push subscription for the named account and token. +func (suite *PushTestSuite) getSubscription( + accountFixtureName string, + tokenFixtureName string, + expectedHTTPStatus int, +) (*apimodel.WebPushSubscription, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath + ctx.Request = httptest.NewRequest(http.MethodGet, requestUrl, nil) + ctx.Request.Header.Set("accept", "application/json") + + // trigger the handler + suite.pushModule.PushSubscriptionGETHandler(ctx) + + // read the response + result := recorder.Result() + defer func() { + _ = result.Body.Close() + }() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + resp := &apimodel.WebPushSubscription{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Get a subscription that should exist. +func (suite *PushTestSuite) TestGetSubscription() { + accountFixtureName := "local_account_1" + // This token should have a subscription associated with it already, with all event types turned on. + tokenFixtureName := "local_account_1" + + subscription, err := suite.getSubscription(accountFixtureName, tokenFixtureName, 200) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + } +} + +// Get a subscription that should not exist, which should fail. +func (suite *PushTestSuite) TestGetMissingSubscription() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + _, err := suite.getSubscription(accountFixtureName, tokenFixtureName, 404) + suite.NoError(err) +} diff --git a/internal/api/client/push/pushsubscriptionpost.go b/internal/api/client/push/pushsubscriptionpost.go new file mode 100644 index 000000000..a7e299894 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionpost.go @@ -0,0 +1,284 @@ +// 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 push + +import ( + "crypto/ecdh" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PushSubscriptionPOSTHandler swagger:operation POST /api/v1/push/subscription pushSubscriptionPost +// +// Create a new Web Push subscription for the current access token, or replace the existing one. +// +// --- +// tags: +// - push +// +// consumes: +// - application/json +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: subscription[endpoint] +// in: formData +// type: string +// required: true +// minLength: 1 +// description: The URL to which Web Push notifications will be sent. +// - +// name: subscription[keys][auth] +// in: formData +// type: string +// required: true +// minLength: 1 +// description: The auth secret, a Base64 encoded string of 16 bytes of random data. +// - +// name: subscription[keys][p256dh] +// in: formData +// type: string +// required: true +// minLength: 1 +// description: The user agent public key, a Base64 encoded string of a public key from an ECDH keypair using the prime256v1 curve. +// - +// name: data[alerts][follow] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone has followed you? +// - +// name: data[alerts][follow_request] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone has requested to follow you? +// - +// name: data[alerts][favourite] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you created has been favourited by someone else? +// - +// name: data[alerts][mention] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone else has mentioned you in a status? +// - +// name: data[alerts][reblog] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you created has been boosted by someone else? +// - +// name: data[alerts][poll] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a poll you voted in or created has ended? +// - +// name: data[alerts][status] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a subscribed account posts a status? +// - +// name: data[alerts][update] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you interacted with has been edited? +// - +// name: data[alerts][admin.sign_up] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a new user has signed up? +// - +// name: data[alerts][admin.report] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a new report has been filed? +// - +// name: data[alerts][pending.favourite] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a fave is pending? +// - +// name: data[alerts][pending.reply] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a reply is pending? +// - +// name: data[alerts][pending.reblog] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a boost is pending? +// +// security: +// - OAuth2 Bearer: +// - push +// +// responses: +// '200': +// description: Web Push subscription for current access token. +// schema: +// "$ref": "#/definitions/webPushSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) PushSubscriptionPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := &apimodel.WebPushSubscriptionCreateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateNormalizeCreate(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiSubscription, errWithCode := m.processor.Push().CreateOrReplace(c, authed.Account.ID, authed.Token.GetAccess(), form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiSubscription) +} + +// validateNormalizeCreate checks subscription endpoint format and keys decodability, +// and copies form fields to their canonical JSON equivalents. +func validateNormalizeCreate(request *apimodel.WebPushSubscriptionCreateRequest) error { + if request.Subscription == nil { + request.Subscription = &apimodel.WebPushSubscriptionRequestSubscription{} + } + + // Normalize and validate endpoint URL. + if request.SubscriptionEndpoint != nil { + request.Subscription.Endpoint = *request.SubscriptionEndpoint + } + + if request.Subscription.Endpoint == "" { + return errors.New("endpoint is required") + } + endpointURL, err := url.Parse(request.Subscription.Endpoint) + if err != nil { + return errors.New("endpoint must be a valid URL") + } + if endpointURL.Scheme != "https" { + return errors.New("endpoint must be an https:// URL") + } + if endpointURL.Host == "" { + return errors.New("endpoint URL must have a host") + } + if endpointURL.Fragment != "" { + return errors.New("endpoint URL must not have a fragment") + } + + // Normalize and validate auth secret. + if request.SubscriptionKeysAuth != nil { + request.Subscription.Keys.Auth = *request.SubscriptionKeysAuth + } + + authBytes, err := base64DecodeAny("auth", request.Subscription.Keys.Auth) + if err != nil { + return err + } + if len(authBytes) != 16 { + return fmt.Errorf("auth must be 16 bytes long, got %d", len(authBytes)) + } + + // Normalize and validate public key. + if request.SubscriptionKeysP256dh != nil { + request.Subscription.Keys.P256dh = *request.SubscriptionKeysP256dh + } + + p256dhBytes, err := base64DecodeAny("p256dh", request.Subscription.Keys.P256dh) + if err != nil { + return err + } + _, err = ecdh.P256().NewPublicKey(p256dhBytes) + if err != nil { + return fmt.Errorf("p256dh must be a valid public key on the NIST P-256 curve: %w", err) + } + + return validateNormalizeUpdate(&request.WebPushSubscriptionUpdateRequest) +} + +// base64DecodeAny tries decoding a string with standard and URL alphabets of Base64, with and without padding. +func base64DecodeAny(name string, value string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.StdEncoding, + base64.URLEncoding, + base64.RawStdEncoding, + base64.RawURLEncoding, + } + + for _, encoding := range encodings { + if bytes, err := encoding.DecodeString(value); err == nil { + return bytes, nil + } + } + + return nil, fmt.Errorf("%s is not valid Base64 data", name) +} diff --git a/internal/api/client/push/pushsubscriptionpost_test.go b/internal/api/client/push/pushsubscriptionpost_test.go new file mode 100644 index 000000000..bdd22d729 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionpost_test.go @@ -0,0 +1,346 @@ +// 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 push_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +// postSubscription creates or replaces the push subscription for the named account and token. +// It only allows updating two event types if using the form API. Add more if you need them. +func (suite *PushTestSuite) postSubscription( + accountFixtureName string, + tokenFixtureName string, + endpoint *string, + auth *string, + p256dh *string, + alertsMention *bool, + alertsStatus *bool, + requestJson *string, + expectedHTTPStatus int, +) (*apimodel.WebPushSubscription, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath + ctx.Request = httptest.NewRequest(http.MethodPost, requestUrl, nil) + ctx.Request.Header.Set("accept", "application/json") + + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if endpoint != nil { + ctx.Request.Form["subscription[endpoint]"] = []string{*endpoint} + } + if auth != nil { + ctx.Request.Form["subscription[keys][auth]"] = []string{*auth} + } + if p256dh != nil { + ctx.Request.Form["subscription[keys][p256dh]"] = []string{*p256dh} + } + if alertsMention != nil { + ctx.Request.Form["data[alerts][mention]"] = []string{strconv.FormatBool(*alertsMention)} + } + if alertsStatus != nil { + ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)} + } + } + + // trigger the handler + suite.pushModule.PushSubscriptionPOSTHandler(ctx) + + // read the response + result := recorder.Result() + defer func() { + _ = result.Body.Close() + }() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + resp := &apimodel.WebPushSubscription{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Create a new subscription. +func (suite *PushTestSuite) TestPostSubscription() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + endpoint := "https://example.test/push" + auth := "cgna/fzrYLDQyPf5hD7IsA==" + p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + alertsMention := true + alertsStatus := false + subscription, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + &endpoint, + &auth, + &p256dh, + &alertsMention, + &alertsStatus, + nil, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + // Omitted event types should default to off. + suite.False(subscription.Alerts.Favourite) + } +} + +// Create a new subscription with only required fields. +func (suite *PushTestSuite) TestPostSubscriptionMinimal() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + endpoint := "https://example.test/push" + auth := "cgna/fzrYLDQyPf5hD7IsA==" + p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + subscription, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + &endpoint, + &auth, + &p256dh, + nil, + nil, + nil, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + // All event types should default to off. + suite.False(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + suite.False(subscription.Alerts.Favourite) + } +} + +// Create a new subscription with a missing endpoint, which should fail. +func (suite *PushTestSuite) TestPostInvalidSubscription() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + // No endpoint. + auth := "cgna/fzrYLDQyPf5hD7IsA==" + p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + alertsMention := true + alertsStatus := false + _, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + nil, + &auth, + &p256dh, + &alertsMention, + &alertsStatus, + nil, + 422, + ) + suite.NoError(err) +} + +// Create a new subscription, using the JSON format. +func (suite *PushTestSuite) TestPostSubscriptionJSON() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + requestJson := `{ + "subscription": { + "endpoint": "https://example.test/push", + "keys": { + "auth": "cgna/fzrYLDQyPf5hD7IsA==", + "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + } + }, + "data": { + "alerts": { + "mention": true, + "status": false + } + } + }` + subscription, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + nil, + nil, + nil, + nil, + nil, + &requestJson, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + // Omitted event types should default to off. + suite.False(subscription.Alerts.Favourite) + } +} + +// Create a new subscription, using the JSON format and only required fields. +func (suite *PushTestSuite) TestPostSubscriptionJSONMinimal() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + requestJson := `{ + "subscription": { + "endpoint": "https://example.test/push", + "keys": { + "auth": "cgna/fzrYLDQyPf5hD7IsA==", + "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + } + } + }` + subscription, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + nil, + nil, + nil, + nil, + nil, + &requestJson, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + // All event types should default to off. + suite.False(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + suite.False(subscription.Alerts.Favourite) + } +} + +// Create a new subscription with a missing endpoint, using the JSON format, which should fail. +func (suite *PushTestSuite) TestPostInvalidSubscriptionJSON() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + // No endpoint. + requestJson := `{ + "subscription": { + "keys": { + "auth": "cgna/fzrYLDQyPf5hD7IsA==", + "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + } + }, + "data": { + "alerts": { + "mention": true, + "status": false + } + } + }` + _, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + nil, + nil, + nil, + nil, + nil, + &requestJson, + 422, + ) + suite.NoError(err) +} + +// Replace a subscription that already exists. +func (suite *PushTestSuite) TestPostExistingSubscription() { + accountFixtureName := "local_account_1" + // This token should have a subscription associated with it already, with all event types turned on. + tokenFixtureName := "local_account_1" + + endpoint := "https://example.test/push" + auth := "JMFtMRgZaeHpwsDjBnhcmQ==" + p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=" + alertsMention := true + alertsStatus := false + subscription, err := suite.postSubscription( + accountFixtureName, + tokenFixtureName, + &endpoint, + &auth, + &p256dh, + &alertsMention, + &alertsStatus, + nil, + 200, + ) + if suite.NoError(err) { + suite.NotEqual(suite.testWebPushSubscriptions["local_account_1_token_1"].ID, subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + // Omitted event types should default to off. + suite.False(subscription.Alerts.Favourite) + } +} diff --git a/internal/api/client/push/pushsubscriptionput.go b/internal/api/client/push/pushsubscriptionput.go new file mode 100644 index 000000000..06575f4ee --- /dev/null +++ b/internal/api/client/push/pushsubscriptionput.go @@ -0,0 +1,232 @@ +// 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 push + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PushSubscriptionPUTHandler swagger:operation PUT /api/v1/push/subscription pushSubscriptionPut +// +// Update the Web Push subscription for the current access token. +// Only which notifications you receive can be updated. +// +// --- +// tags: +// - push +// +// consumes: +// - application/json +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: data[alerts][follow] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone has followed you? +// - +// name: data[alerts][follow_request] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone has requested to follow you? +// - +// name: data[alerts][favourite] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you created has been favourited by someone else? +// - +// name: data[alerts][mention] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when someone else has mentioned you in a status? +// - +// name: data[alerts][reblog] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you created has been boosted by someone else? +// - +// name: data[alerts][poll] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a poll you voted in or created has ended? +// - +// name: data[alerts][status] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a subscribed account posts a status? +// - +// name: data[alerts][update] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a status you interacted with has been edited? +// - +// name: data[alerts][admin.sign_up] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a new user has signed up? +// - +// name: data[alerts][admin.report] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a new report has been filed? +// - +// name: data[alerts][pending.favourite] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a fave is pending? +// - +// name: data[alerts][pending.reply] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a reply is pending? +// - +// name: data[alerts][pending.reblog] +// in: formData +// type: boolean +// default: false +// description: Receive a push notification when a boost is pending? +// +// security: +// - OAuth2 Bearer: +// - push +// +// responses: +// '200': +// description: Web Push subscription for current access token. +// schema: +// "$ref": "#/definitions/webPushSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: This access token doesn't have an associated subscription. +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) PushSubscriptionPUTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := &apimodel.WebPushSubscriptionUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateNormalizeUpdate(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiSubscription, errWithCode := m.processor.Push().Update(c, authed.Token.GetAccess(), form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiSubscription) +} + +// validateNormalizeUpdate copies form fields to their canonical JSON equivalents. +func validateNormalizeUpdate(request *apimodel.WebPushSubscriptionUpdateRequest) error { + if request.Data == nil { + request.Data = &apimodel.WebPushSubscriptionRequestData{} + } + + if request.Data.Alerts == nil { + request.Data.Alerts = &apimodel.WebPushSubscriptionAlerts{} + } + + if request.DataAlertsFollow != nil { + request.Data.Alerts.Follow = *request.DataAlertsFollow + } + if request.DataAlertsFollowRequest != nil { + request.Data.Alerts.FollowRequest = *request.DataAlertsFollowRequest + } + if request.DataAlertsMention != nil { + request.Data.Alerts.Mention = *request.DataAlertsMention + } + if request.DataAlertsReblog != nil { + request.Data.Alerts.Reblog = *request.DataAlertsReblog + } + if request.DataAlertsPoll != nil { + request.Data.Alerts.Poll = *request.DataAlertsPoll + } + if request.DataAlertsStatus != nil { + request.Data.Alerts.Status = *request.DataAlertsStatus + } + if request.DataAlertsUpdate != nil { + request.Data.Alerts.Update = *request.DataAlertsUpdate + } + if request.DataAlertsAdminSignup != nil { + request.Data.Alerts.AdminSignup = *request.DataAlertsAdminSignup + } + if request.DataAlertsAdminReport != nil { + request.Data.Alerts.AdminReport = *request.DataAlertsAdminReport + } + if request.DataAlertsPendingFavourite != nil { + request.Data.Alerts.PendingFavourite = *request.DataAlertsPendingFavourite + } + if request.DataAlertsPendingReply != nil { + request.Data.Alerts.PendingReply = *request.DataAlertsPendingReply + } + if request.DataAlertsPendingReblog != nil { + request.Data.Alerts.Reblog = *request.DataAlertsPendingReblog + } + + return nil +} diff --git a/internal/api/client/push/pushsubscriptionput_test.go b/internal/api/client/push/pushsubscriptionput_test.go new file mode 100644 index 000000000..924e3d475 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionput_test.go @@ -0,0 +1,176 @@ +// 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 push_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/push" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +// putSubscription updates the push subscription for the named account and token. +// It only allows updating two event types if using the form API. Add more if you need them. +func (suite *PushTestSuite) putSubscription( + accountFixtureName string, + tokenFixtureName string, + alertsMention *bool, + alertsStatus *bool, + requestJson *string, + expectedHTTPStatus int, +) (*apimodel.WebPushSubscription, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath + ctx.Request = httptest.NewRequest(http.MethodPut, requestUrl, nil) + ctx.Request.Header.Set("accept", "application/json") + + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if alertsMention != nil { + ctx.Request.Form["data[alerts][mention]"] = []string{strconv.FormatBool(*alertsMention)} + } + if alertsStatus != nil { + ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)} + } + } + + // trigger the handler + suite.pushModule.PushSubscriptionPUTHandler(ctx) + + // read the response + result := recorder.Result() + defer func() { + _ = result.Body.Close() + }() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + resp := &apimodel.WebPushSubscription{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Update a subscription that already exists. +func (suite *PushTestSuite) TestPutSubscription() { + accountFixtureName := "local_account_1" + // This token should have a subscription associated with it already, with all event types turned on. + tokenFixtureName := "local_account_1" + + alertsMention := true + alertsStatus := false + subscription, err := suite.putSubscription( + accountFixtureName, + tokenFixtureName, + &alertsMention, + &alertsStatus, + nil, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + // Omitted event types should default to off. + suite.False(subscription.Alerts.Favourite) + } +} + +// Update a subscription that already exists, using the JSON format. +func (suite *PushTestSuite) TestPutSubscriptionJSON() { + accountFixtureName := "local_account_1" + // This token should have a subscription associated with it already, with all event types turned on. + tokenFixtureName := "local_account_1" + + requestJson := `{ + "data": { + "alerts": { + "mention": true, + "status": false + } + } + }` + subscription, err := suite.putSubscription( + accountFixtureName, + tokenFixtureName, + nil, + nil, + &requestJson, + 200, + ) + if suite.NoError(err) { + suite.NotEmpty(subscription.ID) + suite.NotEmpty(subscription.Endpoint) + suite.NotEmpty(subscription.ServerKey) + suite.True(subscription.Alerts.Mention) + suite.False(subscription.Alerts.Status) + // Omitted event types should default to off. + suite.False(subscription.Alerts.Favourite) + } +} + +// Update a subscription that does not exist, which should fail. +func (suite *PushTestSuite) TestPutMissingSubscription() { + accountFixtureName := "local_account_1" + // This token should not have a subscription. + tokenFixtureName := "local_account_1_user_authorization_token" + + alertsMention := true + alertsStatus := false + _, err := suite.putSubscription( + accountFixtureName, + tokenFixtureName, + &alertsMention, + &alertsStatus, + nil, + 404, + ) + suite.NoError(err) +} diff --git a/internal/api/client/reports/reports_test.go b/internal/api/client/reports/reports_test.go index 031fd953a..89240a4b1 100644 --- a/internal/api/client/reports/reports_test.go +++ b/internal/api/client/reports/reports_test.go @@ -91,7 +91,13 @@ func (suite *ReportsStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.reportsModule = reports.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/search/search_test.go b/internal/api/client/search/search_test.go index fecb30d38..219966c7c 100644 --- a/internal/api/client/search/search_test.go +++ b/internal/api/client/search/search_test.go @@ -95,7 +95,13 @@ func (suite *SearchStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.searchModule = search.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go index 5a4473344..c5f2838e8 100644 --- a/internal/api/client/statuses/status_test.go +++ b/internal/api/client/statuses/status_test.go @@ -211,7 +211,13 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.statusModule = statuses.New(suite.processor) testrig.StartWorkers(&suite.state, suite.processor.Workers()) diff --git a/internal/api/client/streaming/streaming_test.go b/internal/api/client/streaming/streaming_test.go index 03cd6f434..00ad2de03 100644 --- a/internal/api/client/streaming/streaming_test.go +++ b/internal/api/client/streaming/streaming_test.go @@ -111,7 +111,13 @@ func (suite *StreamingTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.streamingModule = streaming.New(suite.processor, 1, 4096) } diff --git a/internal/api/client/tags/tags_test.go b/internal/api/client/tags/tags_test.go index 71a84435a..c24574d47 100644 --- a/internal/api/client/tags/tags_test.go +++ b/internal/api/client/tags/tags_test.go @@ -96,7 +96,13 @@ func (suite *TagsTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.tagsModule = tags.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/api/client/user/emailchange_test.go b/internal/api/client/user/emailchange_test.go index fce96c144..868ad766c 100644 --- a/internal/api/client/user/emailchange_test.go +++ b/internal/api/client/user/emailchange_test.go @@ -44,7 +44,8 @@ func (suite *EmailChangeTestSuite) TestEmailChangePOST() { storage := testrig.NewInMemoryStorage() sentEmails := make(map[string]string) emailSender := testrig.NewEmailSender("../../../../web/template/", sentEmails) - processor := testrig.NewTestProcessor(state, suite.federator, emailSender, suite.mediaManager) + webPushSender := testrig.NewNoopWebPushSender() + processor := testrig.NewTestProcessor(state, suite.federator, emailSender, webPushSender, suite.mediaManager) testrig.StartWorkers(state, processor.Workers()) userModule := user.New(processor) testrig.StandardDBSetup(state.DB, suite.testAccounts) diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go index 320c743f8..8cf359cd8 100644 --- a/internal/api/client/user/user_test.go +++ b/internal/api/client/user/user_test.go @@ -86,8 +86,21 @@ func (suite *UserStandardTestSuite) SetupTest() { ) suite.mediaManager = testrig.NewTestMediaManager(&suite.state) - suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, testrig.NewEmailSender("../../../../web/template/", nil), suite.mediaManager) + suite.federator = testrig.NewTestFederator( + &suite.state, + testrig.NewTestTransportController( + &suite.state, + testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), + ), + suite.mediaManager, + ) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + testrig.NewEmailSender("../../../../web/template/", nil), + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.userModule = user.New(suite.processor) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/fileserver/fileserver_test.go b/internal/api/fileserver/fileserver_test.go index af6f125dc..9b0580e92 100644 --- a/internal/api/fileserver/fileserver_test.go +++ b/internal/api/fileserver/fileserver_test.go @@ -75,8 +75,21 @@ func (suite *FileserverTestSuite) SetupSuite() { suite.state.Storage = suite.storage suite.mediaManager = testrig.NewTestMediaManager(&suite.state) - suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")), suite.mediaManager) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.federator = testrig.NewTestFederator( + &suite.state, + testrig.NewTestTransportController( + &suite.state, + testrig.NewMockHTTPClient(nil, "../../../testrig/media"), + ), + suite.mediaManager, + ) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.tc = typeutils.NewConverter(&suite.state) diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go index 982ed0c63..b3d11dee2 100644 --- a/internal/api/model/instancev2.go +++ b/internal/api/model/instancev2.go @@ -174,6 +174,8 @@ type InstanceV2Configuration struct { Emojis InstanceConfigurationEmojis `json:"emojis"` // True if instance is running with OIDC as auth/identity backend, else omitted. OIDCEnabled bool `json:"oidc_enabled,omitempty"` + // Instance VAPID configuration. + VAPID InstanceV2ConfigurationVAPID `json:"vapid"` } // Information about registering for this instance. @@ -204,3 +206,11 @@ type InstanceV2Contact struct { // Key/value not present if no contact account set. Account *Account `json:"account,omitempty"` } + +// InstanceV2ConfigurationVAPID holds the instance's VAPID configuration. +// +// swagger:model instanceV2ConfigurationVAPID +type InstanceV2ConfigurationVAPID struct { + // The instance's VAPID public key, Base64-encoded. + PublicKey string `json:"public_key"` +} diff --git a/internal/api/model/pushsubscription.go b/internal/api/model/pushsubscription.go deleted file mode 100644 index 1e7708d94..000000000 --- a/internal/api/model/pushsubscription.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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 model - -// PushSubscription represents a subscription to the push streaming server. -type PushSubscription struct { - // The id of the push subscription in the database. - ID string `json:"id"` - // Where push alerts will be sent to. - Endpoint string `json:"endpoint"` - // The streaming server's VAPID key. - ServerKey string `json:"server_key"` - // Which alerts should be delivered to the endpoint. - Alerts *PushSubscriptionAlerts `json:"alerts"` -} - -// PushSubscriptionAlerts represents the specific alerts that this push subscription will give. -type PushSubscriptionAlerts struct { - // Receive a push notification when someone has followed you? - Follow bool `json:"follow"` - // Receive a push notification when a status you created has been favourited by someone else? - Favourite bool `json:"favourite"` - // Receive a push notification when someone else has mentioned you in a status? - Mention bool `json:"mention"` - // Receive a push notification when a status you created has been boosted by someone else? - Reblog bool `json:"reblog"` - // Receive a push notification when a poll you voted in or created has ended? - Poll bool `json:"poll"` -} diff --git a/internal/api/model/webpushnotification.go b/internal/api/model/webpushnotification.go new file mode 100644 index 000000000..5d7a593fc --- /dev/null +++ b/internal/api/model/webpushnotification.go @@ -0,0 +1,52 @@ +// 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 model + +// WebPushNotification represents a notification summary delivered to the client by the Web Push server. +// It does not contain an entire Notification, just the NotificationID and some preview information. +// It is not used in the client API directly, but is included in the API doc for decoding Web Push notifications. +// +// swagger:model webPushNotification +type WebPushNotification struct { + // NotificationID is the Notification.ID of the referenced Notification. + NotificationID string `json:"notification_id"` + + // NotificationType is the Notification.Type of the referenced Notification. + NotificationType string `json:"notification_type"` + + // Title is a title for the notification, + // generally describing an action taken by a user. + Title string `json:"title"` + + // Body is a preview of the notification body, + // such as the first line of a status's CW or text, + // or the first line of an account bio. + Body string `json:"body"` + + // Icon is an image URL that can be displayed with the notification, + // normally the account's avatar. + Icon string `json:"icon"` + + // PreferredLocale is a BCP 47 language tag for the receiving user's locale. + PreferredLocale string `json:"preferred_locale"` + + // AccessToken is the access token associated with the Web Push subscription. + // I don't know why this is sent, given that the client should know that already, + // but Feditext does use it. + AccessToken string `json:"access_token"` +} diff --git a/internal/api/model/webpushsubscription.go b/internal/api/model/webpushsubscription.go new file mode 100644 index 000000000..a28bb7294 --- /dev/null +++ b/internal/api/model/webpushsubscription.go @@ -0,0 +1,157 @@ +// 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 model + +// WebPushSubscription represents a subscription to a Web Push server. +// +// swagger:model webPushSubscription +type WebPushSubscription struct { + // The id of the push subscription in the database. + ID string `json:"id"` + + // Where push alerts will be sent to. + Endpoint string `json:"endpoint"` + + // The streaming server's VAPID public key. + ServerKey string `json:"server_key"` + + // Which alerts should be delivered to the endpoint. + Alerts WebPushSubscriptionAlerts `json:"alerts"` + + // Which accounts should generate notifications. + Policy WebPushNotificationPolicy `json:"policy"` + + // Whether the subscription uses RFC or pre-RFC Web Push standards. + // For GotoSocial, this is always true. + Standard bool `json:"standard"` +} + +// WebPushSubscriptionAlerts represents the specific events that this Web Push subscription will receive. +// +// swagger:model webPushSubscriptionAlerts +type WebPushSubscriptionAlerts struct { + // Receive a push notification when someone has followed you? + Follow bool `json:"follow"` + + // Receive a push notification when someone has requested to follow you? + FollowRequest bool `json:"follow_request"` + + // Receive a push notification when a status you created has been favourited by someone else? + Favourite bool `json:"favourite"` + + // Receive a push notification when someone else has mentioned you in a status? + Mention bool `json:"mention"` + + // Receive a push notification when a status you created has been boosted by someone else? + Reblog bool `json:"reblog"` + + // Receive a push notification when a poll you voted in or created has ended? + Poll bool `json:"poll"` + + // Receive a push notification when a subscribed account posts a status? + Status bool `json:"status"` + + // Receive a push notification when a status you interacted with has been edited? + Update bool `json:"update"` + + // Receive a push notification when a new user has signed up? + AdminSignup bool `json:"admin.sign_up"` + + // Receive a push notification when a new report has been filed? + AdminReport bool `json:"admin.report"` + + // Receive a push notification when a fave is pending? + PendingFavourite bool `json:"pending.favourite"` + + // Receive a push notification when a reply is pending? + PendingReply bool `json:"pending.reply"` + + // Receive a push notification when a boost is pending? + PendingReblog bool `json:"pending.reblog"` +} + +// WebPushSubscriptionCreateRequest captures params for creating or replacing a Web Push subscription. +// +// swagger:ignore +type WebPushSubscriptionCreateRequest struct { + Subscription *WebPushSubscriptionRequestSubscription `form:"-" json:"subscription"` + + SubscriptionEndpoint *string `form:"subscription[endpoint]" json:"-"` + SubscriptionKeysAuth *string `form:"subscription[keys][auth]" json:"-"` + SubscriptionKeysP256dh *string `form:"subscription[keys][p256dh]" json:"-"` + + WebPushSubscriptionUpdateRequest +} + +// WebPushSubscriptionRequestSubscription is the part of a Web Push subscription that is fixed at creation. +// +// swagger:ignore +type WebPushSubscriptionRequestSubscription struct { + // Endpoint is the URL to which Web Push notifications will be sent. + Endpoint string `json:"endpoint"` + + Keys WebPushSubscriptionRequestSubscriptionKeys `json:"keys"` +} + +// WebPushSubscriptionRequestSubscriptionKeys is the part of a Web Push subscription that contains auth secrets. +// +// swagger:ignore +type WebPushSubscriptionRequestSubscriptionKeys struct { + // Auth is the auth secret, a Base64 encoded string of 16 bytes of random data. + Auth string `json:"auth"` + + // P256dh is the user agent public key, a Base64 encoded string of a public key from an ECDH keypair using the prime256v1 curve. + P256dh string `json:"p256dh"` +} + +// WebPushSubscriptionUpdateRequest captures params for updating a Web Push subscription. +// +// swagger:ignore +type WebPushSubscriptionUpdateRequest struct { + Data *WebPushSubscriptionRequestData `form:"-" json:"data"` + + DataAlertsFollow *bool `form:"data[alerts][follow]" json:"-"` + DataAlertsFollowRequest *bool `form:"data[alerts][follow_request]" json:"-"` + DataAlertsFavourite *bool `form:"data[alerts][favourite]" json:"-"` + DataAlertsMention *bool `form:"data[alerts][mention]" json:"-"` + DataAlertsReblog *bool `form:"data[alerts][reblog]" json:"-"` + DataAlertsPoll *bool `form:"data[alerts][poll]" json:"-"` + DataAlertsStatus *bool `form:"data[alerts][status]" json:"-"` + DataAlertsUpdate *bool `form:"data[alerts][update]" json:"-"` + DataAlertsAdminSignup *bool `form:"data[alerts][admin.sign_up]" json:"-"` + DataAlertsAdminReport *bool `form:"data[alerts][admin.report]" json:"-"` + DataAlertsPendingFavourite *bool `form:"data[alerts][pending.favourite]" json:"-"` + DataAlertsPendingReply *bool `form:"data[alerts][pending.reply]" json:"-"` + DataAlertsPendingReblog *bool `form:"data[alerts][pending.reblog]" json:"-"` +} + +// WebPushSubscriptionRequestData is the part of a Web Push subscription that can be changed after creation. +// +// swagger:ignore +type WebPushSubscriptionRequestData struct { + // Alerts selects the specific events that this Web Push subscription will receive. + Alerts *WebPushSubscriptionAlerts `form:"-" json:"alerts"` +} + +// WebPushNotificationPolicy names sets of accounts that can generate notifications. +type WebPushNotificationPolicy string + +const ( + // WebPushNotificationPolicyAll allows all accounts to send notifications to the subscribing user. + WebPushNotificationPolicyAll WebPushNotificationPolicy = "all" +) diff --git a/internal/api/wellknown/webfinger/webfinger_test.go b/internal/api/wellknown/webfinger/webfinger_test.go index 76c1fb5bb..234c1ad16 100644 --- a/internal/api/wellknown/webfinger/webfinger_test.go +++ b/internal/api/wellknown/webfinger/webfinger_test.go @@ -94,7 +94,13 @@ func (suite *WebfingerStandardTestSuite) SetupTest() { suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) suite.webfingerModule = webfinger.New(suite.processor) suite.oauthServer = testrig.NewTestOauthServer(suite.db) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go index b3aec57fe..4bb6f323d 100644 --- a/internal/api/wellknown/webfinger/webfingerget_test.go +++ b/internal/api/wellknown/webfinger/webfingerget_test.go @@ -98,6 +98,7 @@ func (suite *WebfingerGetTestSuite) funkifyAccountDomain(host string, accountDom testrig.NewTestMediaManager(&suite.state), &suite.state, suite.emailSender, + testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 6f925e24f..5771b4e95 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -117,6 +117,8 @@ func (c *Caches) Init() { c.initUserMute() c.initUserMuteIDs() c.initWebfinger() + c.initWebPushSubscription() + c.initWebPushSubscriptionIDs() c.initVisibility() c.initStatusesFilterableFields() } diff --git a/internal/cache/db.go b/internal/cache/db.go index 1052446c4..180d81907 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -258,6 +258,15 @@ type DBCaches struct { // UserMuteIDs provides access to the user mute IDs database cache. UserMuteIDs SliceCache[string] + + // VAPIDKeyPair caches the server's VAPID key pair. + VAPIDKeyPair atomic.Pointer[gtsmodel.VAPIDKeyPair] + + // WebPushSubscription provides access to the gtsmodel WebPushSubscription database cache. + WebPushSubscription StructCache[*gtsmodel.WebPushSubscription] + + // WebPushSubscriptionIDs provides access to the Web Push subscription IDs database cache. + WebPushSubscriptionIDs SliceCache[string] } // NOTE: @@ -1579,9 +1588,10 @@ func (c *Caches) initToken() { {Fields: "Refresh"}, {Fields: "ClientID", Multiple: true}, }, - MaxSize: cap, - IgnoreErr: ignoreErrors, - Copy: copyF, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateToken, }) } @@ -1691,3 +1701,40 @@ func (c *Caches) initUserMuteIDs() { c.DB.UserMuteIDs.Init(0, cap) } + +func (c *Caches) initWebPushSubscription() { + cap := calculateResultCacheMax( + sizeofWebPushSubscription(), // model in-mem size. + config.GetCacheWebPushSubscriptionMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(s1 *gtsmodel.WebPushSubscription) *gtsmodel.WebPushSubscription { + s2 := new(gtsmodel.WebPushSubscription) + *s2 = *s1 + return s2 + } + + c.DB.WebPushSubscription.Init(structr.CacheConfig[*gtsmodel.WebPushSubscription]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "TokenID"}, + {Fields: "AccountID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Invalidate: c.OnInvalidateWebPushSubscription, + Copy: copyF, + }) +} + +func (c *Caches) initWebPushSubscriptionIDs() { + cap := calculateSliceCacheMax( + config.GetCacheWebPushSubscriptionIDsMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.DB.WebPushSubscriptionIDs.Init(0, cap) +} diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 42d7b7399..555c73cd7 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -283,6 +283,11 @@ func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) { c.DB.StatusFaveIDs.Invalidate(fave.StatusID) } +func (c *Caches) OnInvalidateToken(token *gtsmodel.Token) { + // Invalidate token's push subscription. + c.DB.WebPushSubscription.Invalidate("ID", token.ID) +} + func (c *Caches) OnInvalidateUser(user *gtsmodel.User) { // Invalidate local account ID cached visibility. c.Visibility.Invalidate("ItemID", user.AccountID) @@ -296,3 +301,8 @@ func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) { // Invalidate source account's user mute lists. c.DB.UserMuteIDs.Invalidate(mute.AccountID) } + +func (c *Caches) OnInvalidateWebPushSubscription(subscription *gtsmodel.WebPushSubscription) { + // Invalidate source account's Web Push subscription list. + c.DB.WebPushSubscriptionIDs.Invalidate(subscription.AccountID) +} diff --git a/internal/cache/size.go b/internal/cache/size.go index 24101683a..c96a3cd2e 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -66,6 +66,14 @@ you'll make society more equitable for all if you're not careful! :hammer_sickle // be a serialized string of almost any type, so we pick a // nice serialized key size on the upper end of normal. sizeofResultKey = 2 * sizeofIDStr + + // exampleWebPushAuth is a Base64-encoded 16-byte random auth secret. + // This secret is consumed as Base64 by webpush-go. + exampleWebPushAuth = "ZVxqlt5fzVgmSz2aqiA2XQ==" + + // exampleWebPushP256dh is a Base64-encoded DH P-256 public key. + // This secret is consumed as Base64 by webpush-go. + exampleWebPushP256dh = "OrpejO16gV97uBXew/T0I7YoUv/CX8fz0z4g8RrQ+edXJqQPjX3XVSo2P0HhcCpCOR1+Dzj5LFcK9jYNqX7SBg==" ) var ( @@ -576,7 +584,7 @@ func sizeofMove() uintptr { func sizeofNotification() uintptr { return uintptr(size.Of(>smodel.Notification{ ID: exampleID, - NotificationType: gtsmodel.NotificationFave, + NotificationType: gtsmodel.NotificationFavourite, CreatedAt: exampleTime, TargetAccountID: exampleID, OriginAccountID: exampleID, @@ -821,3 +829,11 @@ func sizeofUserMute() uintptr { Notifications: util.Ptr(false), })) } + +func sizeofWebPushSubscription() uintptr { + return uintptr(size.Of(>smodel.WebPushSubscription{ + TokenID: exampleID, + Auth: exampleWebPushAuth, + P256dh: exampleWebPushP256dh, + })) +} diff --git a/internal/config/config.go b/internal/config/config.go index 72154b3f2..f77c86c50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -252,6 +252,8 @@ type CacheConfiguration struct { UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` + WebPushSubscriptionMemRatio float64 `name:"web-push-subscription-mem-ratio"` + WebPushSubscriptionIDsMemRatio float64 `name:"web-push-subscription-ids-mem-ratio"` VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 8c2ae90de..157dfde0a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -213,6 +213,8 @@ var Defaults = Configuration{ UserMuteMemRatio: 2, UserMuteIDsMemRatio: 3, WebfingerMemRatio: 0.1, + WebPushSubscriptionMemRatio: 1, + WebPushSubscriptionIDsMemRatio: 1, VisibilityMemRatio: 2, }, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index e1c41638c..fd1b86898 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -4274,6 +4274,64 @@ func GetCacheWebfingerMemRatio() float64 { return global.GetCacheWebfingerMemRat // SetCacheWebfingerMemRatio safely sets the value for global configuration 'Cache.WebfingerMemRatio' field func SetCacheWebfingerMemRatio(v float64) { global.SetCacheWebfingerMemRatio(v) } +// GetCacheWebPushSubscriptionMemRatio safely fetches the Configuration value for state's 'Cache.WebPushSubscriptionMemRatio' field +func (st *ConfigState) GetCacheWebPushSubscriptionMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.WebPushSubscriptionMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheWebPushSubscriptionMemRatio safely sets the Configuration value for state's 'Cache.WebPushSubscriptionMemRatio' field +func (st *ConfigState) SetCacheWebPushSubscriptionMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.WebPushSubscriptionMemRatio = v + st.reloadToViper() +} + +// CacheWebPushSubscriptionMemRatioFlag returns the flag name for the 'Cache.WebPushSubscriptionMemRatio' field +func CacheWebPushSubscriptionMemRatioFlag() string { return "cache-web-push-subscription-mem-ratio" } + +// GetCacheWebPushSubscriptionMemRatio safely fetches the value for global configuration 'Cache.WebPushSubscriptionMemRatio' field +func GetCacheWebPushSubscriptionMemRatio() float64 { + return global.GetCacheWebPushSubscriptionMemRatio() +} + +// SetCacheWebPushSubscriptionMemRatio safely sets the value for global configuration 'Cache.WebPushSubscriptionMemRatio' field +func SetCacheWebPushSubscriptionMemRatio(v float64) { global.SetCacheWebPushSubscriptionMemRatio(v) } + +// GetCacheWebPushSubscriptionIDsMemRatio safely fetches the Configuration value for state's 'Cache.WebPushSubscriptionIDsMemRatio' field +func (st *ConfigState) GetCacheWebPushSubscriptionIDsMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.WebPushSubscriptionIDsMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheWebPushSubscriptionIDsMemRatio safely sets the Configuration value for state's 'Cache.WebPushSubscriptionIDsMemRatio' field +func (st *ConfigState) SetCacheWebPushSubscriptionIDsMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.WebPushSubscriptionIDsMemRatio = v + st.reloadToViper() +} + +// CacheWebPushSubscriptionIDsMemRatioFlag returns the flag name for the 'Cache.WebPushSubscriptionIDsMemRatio' field +func CacheWebPushSubscriptionIDsMemRatioFlag() string { + return "cache-web-push-subscription-ids-mem-ratio" +} + +// GetCacheWebPushSubscriptionIDsMemRatio safely fetches the value for global configuration 'Cache.WebPushSubscriptionIDsMemRatio' field +func GetCacheWebPushSubscriptionIDsMemRatio() float64 { + return global.GetCacheWebPushSubscriptionIDsMemRatio() +} + +// SetCacheWebPushSubscriptionIDsMemRatio safely sets the value for global configuration 'Cache.WebPushSubscriptionIDsMemRatio' field +func SetCacheWebPushSubscriptionIDsMemRatio(v float64) { + global.SetCacheWebPushSubscriptionIDsMemRatio(v) +} + // GetCacheVisibilityMemRatio safely fetches the Configuration value for state's 'Cache.VisibilityMemRatio' field func (st *ConfigState) GetCacheVisibilityMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/application.go b/internal/db/application.go index b71e593c2..5a4068431 100644 --- a/internal/db/application.go +++ b/internal/db/application.go @@ -48,6 +48,9 @@ type Application interface { // GetAllTokens ... GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error) + // GetTokenByID ... + GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error) + // GetTokenByCode ... GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) diff --git a/internal/db/bundb/application.go b/internal/db/bundb/application.go index cbba499b0..92fc5ea2b 100644 --- a/internal/db/bundb/application.go +++ b/internal/db/bundb/application.go @@ -174,6 +174,16 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er return tokens, nil } +func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) { + return a.getTokenBy( + "ID", + func(t *gtsmodel.Token) error { + return a.db.NewSelect().Model(t).Where("? = ?", bun.Ident("id"), code).Scan(ctx) + }, + code, + ) +} + func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) { return a.getTokenBy( "Code", diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index cf612fd2e..c9dd7866d 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -88,6 +88,7 @@ type DBService struct { db.Timeline db.User db.Tombstone + db.WebPush db.WorkerTask db *bun.DB } @@ -301,6 +302,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { db: db, state: state, }, + WebPush: &webPushDB{ + db: db, + state: state, + }, WorkerTask: &workerTaskDB{ db: db, }, diff --git a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go index 6767c6809..5f3eb1409 100644 --- a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go +++ b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go @@ -149,10 +149,10 @@ func notificationEnumMapping[T ~string]() map[T]new_gtsmodel.NotificationType { T(old_gtsmodel.NotificationFollowRequest): new_gtsmodel.NotificationFollowRequest, T(old_gtsmodel.NotificationMention): new_gtsmodel.NotificationMention, T(old_gtsmodel.NotificationReblog): new_gtsmodel.NotificationReblog, - T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFave, + T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFavourite, T(old_gtsmodel.NotificationPoll): new_gtsmodel.NotificationPoll, T(old_gtsmodel.NotificationStatus): new_gtsmodel.NotificationStatus, - T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationSignup, + T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationAdminSignup, T(old_gtsmodel.NotificationPendingFave): new_gtsmodel.NotificationPendingFave, T(old_gtsmodel.NotificationPendingReply): new_gtsmodel.NotificationPendingReply, T(old_gtsmodel.NotificationPendingReblog): new_gtsmodel.NotificationPendingReblog, diff --git a/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go b/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go new file mode 100644 index 000000000..c1a32f6be --- /dev/null +++ b/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go @@ -0,0 +1,51 @@ +// 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" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.VAPIDKeyPair{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go b/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go new file mode 100644 index 000000000..87d966903 --- /dev/null +++ b/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go @@ -0,0 +1,61 @@ +// 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" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.WebPushSubscription{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Model(>smodel.WebPushSubscription{}). + Index("web_push_subscriptions_account_id_idx"). + Column("account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/notification_test.go b/internal/db/bundb/notification_test.go index 8e2fb8031..8cc778071 100644 --- a/internal/db/bundb/notification_test.go +++ b/internal/db/bundb/notification_test.go @@ -66,7 +66,7 @@ func (suite *NotificationTestSuite) spamNotifs() { notif := >smodel.Notification{ ID: notifID, - NotificationType: gtsmodel.NotificationFave, + NotificationType: gtsmodel.NotificationFavourite, CreatedAt: time.Now(), TargetAccountID: targetAccountID, OriginAccountID: originAccountID, diff --git a/internal/db/bundb/webpush.go b/internal/db/bundb/webpush.go new file mode 100644 index 000000000..c61209573 --- /dev/null +++ b/internal/db/bundb/webpush.go @@ -0,0 +1,270 @@ +// 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 bundb + +import ( + "context" + "errors" + + webpushgo "github.com/SherClockHolmes/webpush-go" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/util/xslices" + "github.com/uptrace/bun" +) + +type webPushDB struct { + db *bun.DB + state *state.State +} + +func (w *webPushDB) GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) { + var err error + + vapidKeyPair, err := w.getVAPIDKeyPair(ctx) + if err != nil { + return nil, err + } + if vapidKeyPair != nil { + return vapidKeyPair, nil + } + + // If there aren't any, generate new ones. + vapidKeyPair = >smodel.VAPIDKeyPair{} + if vapidKeyPair.Private, vapidKeyPair.Public, err = webpushgo.GenerateVAPIDKeys(); err != nil { + return nil, gtserror.Newf("error generating VAPID key pair: %w", err) + } + + // Store the keys in the database. + if _, err = w.db.NewInsert(). + Model(vapidKeyPair). + Exec(ctx); // nocollapse + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // Multiple concurrent attempts to generate new keys, and this one didn't win. + // Get the results of the one that did. + return w.getVAPIDKeyPair(ctx) + } + return nil, err + } + + // Cache the keys. + w.state.Caches.DB.VAPIDKeyPair.Store(vapidKeyPair) + + return vapidKeyPair, nil +} + +// getVAPIDKeyPair gets an existing VAPID key pair from cache or DB. +// If there is no existing VAPID key pair, it returns nil, with no error. +func (w *webPushDB) getVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) { + // Look for cached keys. + vapidKeyPair := w.state.Caches.DB.VAPIDKeyPair.Load() + if vapidKeyPair != nil { + return vapidKeyPair, nil + } + + // Look for previously generated keys in the database. + vapidKeyPair = >smodel.VAPIDKeyPair{} + if err := w.db.NewSelect(). + Model(vapidKeyPair). + Limit(1). + Scan(ctx); // nocollapse + err != nil { + if errors.Is(err, db.ErrNoEntries) { + return nil, nil + } + return nil, err + } + + return vapidKeyPair, nil +} + +func (w *webPushDB) DeleteVAPIDKeyPair(ctx context.Context) error { + // Delete any existing keys. + if _, err := w.db.NewTruncateTable(). + Model((*gtsmodel.VAPIDKeyPair)(nil)). + Exec(ctx); // nocollapse + err != nil { + return err + } + + // Clear the key cache. + w.state.Caches.DB.VAPIDKeyPair.Store(nil) + + return nil +} + +func (w *webPushDB) GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) { + subscription, err := w.state.Caches.DB.WebPushSubscription.LoadOne( + "TokenID", + func() (*gtsmodel.WebPushSubscription, error) { + var subscription gtsmodel.WebPushSubscription + err := w.db. + NewSelect(). + Model(&subscription). + Where("? = ?", bun.Ident("token_id"), tokenID). + Scan(ctx) + return &subscription, err + }, + tokenID, + ) + if err != nil { + return nil, err + } + return subscription, nil +} + +func (w *webPushDB) PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error { + return w.state.Caches.DB.WebPushSubscription.Store(subscription, func() error { + _, err := w.db.NewInsert(). + Model(subscription). + Exec(ctx) + return err + }) +} + +func (w *webPushDB) UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error { + // Update database. + result, err := w.db. + NewUpdate(). + Model(subscription). + Column(columns...). + Where("? = ?", bun.Ident("id"), subscription.ID). + Exec(ctx) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting updated row count: %w", err) + } + if rowsAffected == 0 { + return db.ErrNoEntries + } + + // Update cache. + w.state.Caches.DB.WebPushSubscription.Put(subscription) + + return nil +} + +func (w *webPushDB) DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error { + // Deleted partial model for cache invalidation. + var deleted gtsmodel.WebPushSubscription + + // Delete subscription, returning subset of columns used by invalidation hook. + if _, err := w.db.NewDelete(). + Model(&deleted). + Where("? = ?", bun.Ident("token_id"), tokenID). + Returning("?", bun.Ident("account_id")). + Exec(ctx); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Invalidate cached subscription by token ID. + w.state.Caches.DB.WebPushSubscription.Invalidate("TokenID", tokenID) + + // Call invalidate hook directly. + w.state.Caches.OnInvalidateWebPushSubscription(&deleted) + + return nil +} + +func (w *webPushDB) GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error) { + // Fetch IDs of all subscriptions created by this account. + subscriptionIDs, err := loadPagedIDs(&w.state.Caches.DB.WebPushSubscriptionIDs, accountID, nil, func() ([]string, error) { + // Subscription IDs not in cache. Perform DB query. + var subscriptionIDs []string + if _, err := w.db. + NewSelect(). + Model((*gtsmodel.WebPushSubscription)(nil)). + Column("id"). + Where("? = ?", bun.Ident("account_id"), accountID). + Order("id DESC"). + Exec(ctx, &subscriptionIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, err + } + return subscriptionIDs, nil + }) + if err != nil { + return nil, err + } + if len(subscriptionIDs) == 0 { + return nil, nil + } + + // Get each subscription by ID from the cache or DB. + subscriptions, err := w.state.Caches.DB.WebPushSubscription.LoadIDs("ID", + subscriptionIDs, + func(uncached []string) ([]*gtsmodel.WebPushSubscription, error) { + subscriptions := make([]*gtsmodel.WebPushSubscription, 0, len(uncached)) + if err := w.db. + NewSelect(). + Model(&subscriptions). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); // nocollapse + err != nil { + return nil, err + } + return subscriptions, nil + }, + ) + if err != nil { + return nil, err + } + + // Put the subscription structs in the same order as the filter IDs. + xslices.OrderBy( + subscriptions, + subscriptionIDs, + func(subscription *gtsmodel.WebPushSubscription) string { + return subscription.ID + }, + ) + + return subscriptions, nil +} + +func (w *webPushDB) DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error { + // Deleted partial models for cache invalidation. + var deleted []*gtsmodel.WebPushSubscription + + // Delete subscriptions, returning subset of columns. + if _, err := w.db.NewDelete(). + Model(&deleted). + Where("? = ?", bun.Ident("account_id"), accountID). + Returning("?", bun.Ident("account_id")). + Exec(ctx); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Invalidate cached subscriptions by account ID. + w.state.Caches.DB.WebPushSubscription.Invalidate("AccountID", accountID) + + // Call invalidate hooks directly in case those entries weren't cached. + for _, subscription := range deleted { + w.state.Caches.OnInvalidateWebPushSubscription(subscription) + } + + return nil +} diff --git a/internal/db/bundb/webpush_test.go b/internal/db/bundb/webpush_test.go new file mode 100644 index 000000000..8ca83955a --- /dev/null +++ b/internal/db/bundb/webpush_test.go @@ -0,0 +1,81 @@ +// 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 bundb_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" +) + +type WebPushTestSuite struct { + BunDBStandardTestSuite +} + +// Get the text fixture VAPID key pair. +func (suite *WebPushTestSuite) TestGetVAPIDKeyPair() { + ctx := context.Background() + + vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx) + suite.NoError(err) + if !suite.NotNil(vapidKeyPair) { + suite.FailNow("Got a nil VAPID key pair, can't continue") + } + suite.NotEmpty(vapidKeyPair.Private) + suite.NotEmpty(vapidKeyPair.Public) + + // Get it again. It should be the same one. + vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx) + suite.NoError(err) + if suite.NotNil(vapidKeyPair2) { + suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private) + suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public) + } +} + +// Generate a VAPID key pair when there isn't one. +func (suite *WebPushTestSuite) TestGenerateVAPIDKeyPair() { + ctx := context.Background() + + // Delete the text fixture VAPID key pair. + if err := suite.db.DeleteVAPIDKeyPair(ctx); !suite.NoError(err) { + suite.FailNow("Test setup failed: DB error deleting fixture VAPID key pair: %v", err) + } + + // Get a new one. + vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx) + suite.NoError(err) + if !suite.NotNil(vapidKeyPair) { + suite.FailNow("Got a nil VAPID key pair, can't continue") + } + suite.NotEmpty(vapidKeyPair.Private) + suite.NotEmpty(vapidKeyPair.Public) + + // Get it again. It should be the same one. + vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx) + suite.NoError(err) + if suite.NotNil(vapidKeyPair2) { + suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private) + suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public) + } +} + +func TestWebPushTestSuite(t *testing.T) { + suite.Run(t, new(WebPushTestSuite)) +} diff --git a/internal/db/db.go b/internal/db/db.go index 11dd2e507..16796ae49 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -58,5 +58,6 @@ type DB interface { Timeline User Tombstone + WebPush WorkerTask } diff --git a/internal/db/webpush.go b/internal/db/webpush.go new file mode 100644 index 000000000..22bf449de --- /dev/null +++ b/internal/db/webpush.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 db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// WebPush contains functions related to Web Push notifications. +type WebPush interface { + // GetVAPIDKeyPair retrieves the server's existing VAPID key pair, if there is one. + // If there isn't one, it generates a new one, stores it, and returns that. + GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) + + // DeleteVAPIDKeyPair deletes the server's VAPID key pair. + DeleteVAPIDKeyPair(ctx context.Context) error + + // GetWebPushSubscriptionByTokenID retrieves an access token's Web Push subscription. + // There may not be one, in which case an error will be returned. + GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) + + // PutWebPushSubscription creates an access token's Web Push subscription. + PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error + + // UpdateWebPushSubscription updates an access token's Web Push subscription. + // There may not be one, in which case an error will be returned. + UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error + + // DeleteWebPushSubscriptionByTokenID deletes an access token's Web Push subscription, if there is one. + DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error + + // GetWebPushSubscriptionsByAccountID retrieves an account's list of Web Push subscriptions. + GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error) + + // DeleteWebPushSubscriptionsByAccountID deletes an account's list of Web Push subscriptions. + DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error +} diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go index 1ef805081..d160e0883 100644 --- a/internal/gtsmodel/notification.go +++ b/internal/gtsmodel/notification.go @@ -48,13 +48,16 @@ const ( NotificationFollowRequest NotificationType = 2 // NotificationFollowRequest -- someone requested to follow you NotificationMention NotificationType = 3 // NotificationMention -- someone mentioned you in their status NotificationReblog NotificationType = 4 // NotificationReblog -- someone boosted one of your statuses - NotificationFave NotificationType = 5 // NotificationFave -- someone faved/liked one of your statuses + NotificationFavourite NotificationType = 5 // NotificationFavourite -- someone faved/liked one of your statuses NotificationPoll NotificationType = 6 // NotificationPoll -- a poll you voted in or created has ended NotificationStatus NotificationType = 7 // NotificationStatus -- someone you enabled notifications for has posted a status. - NotificationSignup NotificationType = 8 // NotificationSignup -- someone has submitted a new account sign-up to the instance. - NotificationPendingFave NotificationType = 9 // Someone has faved a status of yours, which requires approval by you. - NotificationPendingReply NotificationType = 10 // Someone has replied to a status of yours, which requires approval by you. - NotificationPendingReblog NotificationType = 11 // Someone has boosted a status of yours, which requires approval by you. + NotificationAdminSignup NotificationType = 8 // NotificationAdminSignup -- someone has submitted a new account sign-up to the instance. + NotificationPendingFave NotificationType = 9 // NotificationPendingFave -- Someone has faved a status of yours, which requires approval by you. + NotificationPendingReply NotificationType = 10 // NotificationPendingReply -- Someone has replied to a status of yours, which requires approval by you. + NotificationPendingReblog NotificationType = 11 // NotificationPendingReblog -- Someone has boosted a status of yours, which requires approval by you. + NotificationAdminReport NotificationType = 12 // NotificationAdminReport -- someone has submitted a new report to the instance. + NotificationUpdate NotificationType = 13 // NotificationUpdate -- someone has edited their status. + NotificationTypeNumValues NotificationType = 14 // NotificationTypeNumValues -- 1 + number of max notification type ) // String returns a stringified, frontend API compatible form of NotificationType. @@ -68,13 +71,13 @@ func (t NotificationType) String() string { return "mention" case NotificationReblog: return "reblog" - case NotificationFave: + case NotificationFavourite: return "favourite" case NotificationPoll: return "poll" case NotificationStatus: return "status" - case NotificationSignup: + case NotificationAdminSignup: return "admin.sign_up" case NotificationPendingFave: return "pending.favourite" @@ -82,6 +85,10 @@ func (t NotificationType) String() string { return "pending.reply" case NotificationPendingReblog: return "pending.reblog" + case NotificationAdminReport: + return "admin.report" + case NotificationUpdate: + return "update" default: panic("invalid notification type") } @@ -99,19 +106,23 @@ func ParseNotificationType(in string) NotificationType { case "reblog": return NotificationReblog case "favourite": - return NotificationFave + return NotificationFavourite case "poll": return NotificationPoll case "status": return NotificationStatus case "admin.sign_up": - return NotificationSignup + return NotificationAdminSignup case "pending.favourite": return NotificationPendingFave case "pending.reply": return NotificationPendingReply case "pending.reblog": return NotificationPendingReblog + case "admin.report": + return NotificationAdminReport + case "update": + return NotificationUpdate default: return NotificationUnknown } diff --git a/internal/gtsmodel/vapidkeypair.go b/internal/gtsmodel/vapidkeypair.go new file mode 100644 index 000000000..56b7edda8 --- /dev/null +++ b/internal/gtsmodel/vapidkeypair.go @@ -0,0 +1,28 @@ +// 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 gtsmodel + +// VAPIDKeyPair represents the instance's VAPID keys (stored as Base64 strings). +// This table should only ever have one entry, with a known ID of 0. +// +// See: https://datatracker.ietf.org/doc/html/rfc8292 +type VAPIDKeyPair struct { + ID int `bun:",pk,notnull"` + Public string `bun:",notnull,nullzero"` + Private string `bun:",notnull,nullzero"` +} diff --git a/internal/gtsmodel/webpushsubscription.go b/internal/gtsmodel/webpushsubscription.go new file mode 100644 index 000000000..4aeef654a --- /dev/null +++ b/internal/gtsmodel/webpushsubscription.go @@ -0,0 +1,82 @@ +// 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 gtsmodel + +// WebPushSubscription represents an access token's Web Push subscription. +// There can be at most one per access token. +type WebPushSubscription struct { + // ID of this subscription in the database. + ID string `bun:"type:CHAR(26),pk,nullzero"` + + // AccountID of the local account that created this subscription. + AccountID string `bun:"type:CHAR(26),nullzero,notnull"` + + // TokenID is the ID of the associated access token. + // There can be at most one subscription for any given access token, + TokenID string `bun:"type:CHAR(26),nullzero,notnull,unique"` + + // Endpoint is the URL receiving Web Push notifications for this subscription. + Endpoint string `bun:",nullzero,notnull"` + + // Auth is a Base64-encoded authentication secret. + Auth string `bun:",nullzero,notnull"` + + // 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 WebPushSubscriptionNotificationFlags `bun:",notnull"` +} + +// WebPushSubscriptionNotificationFlags is a bitfield representation of a set of NotificationType. +type WebPushSubscriptionNotificationFlags int64 + +// WebPushSubscriptionNotificationFlagsFromSlice packs a slice of NotificationType into a WebPushSubscriptionNotificationFlags. +func WebPushSubscriptionNotificationFlagsFromSlice(notificationTypes []NotificationType) WebPushSubscriptionNotificationFlags { + var n WebPushSubscriptionNotificationFlags + for _, notificationType := range notificationTypes { + n.Set(notificationType, true) + } + return n +} + +// ToSlice unpacks a WebPushSubscriptionNotificationFlags into a slice of NotificationType. +func (n *WebPushSubscriptionNotificationFlags) ToSlice() []NotificationType { + notificationTypes := make([]NotificationType, 0, NotificationTypeNumValues) + for notificationType := NotificationUnknown; notificationType < NotificationTypeNumValues; notificationType++ { + if n.Get(notificationType) { + notificationTypes = append(notificationTypes, notificationType) + } + } + return notificationTypes +} + +// Get tests to see if a given NotificationType is included in this set of flags. +func (n *WebPushSubscriptionNotificationFlags) Get(notificationType NotificationType) bool { + return *n&(1<<notificationType) != 0 +} + +// Set adds or removes a given NotificationType to or from this set of flags. +func (n *WebPushSubscriptionNotificationFlags) Set(notificationType NotificationType, value bool) { + if value { + *n |= 1 << notificationType + } else { + *n &= ^(1 << notificationType) + } +} diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index c8d1ba5f9..2618fdfc5 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -96,7 +96,7 @@ func (p *Processor) Delete( } // deleteUserAndTokensForAccount deletes the gtsmodel.User and -// any OAuth tokens and applications for the given account. +// any OAuth tokens, applications, and Web Push subscriptions for the given account. // // Callers to this function should already have checked that // this is a local account, or else it won't have a user associated @@ -129,6 +129,10 @@ func (p *Processor) deleteUserAndTokensForAccount(ctx context.Context, account * } } + if err := p.state.DB.DeleteWebPushSubscriptionsByAccountID(ctx, account.ID); err != nil { + return gtserror.Newf("db error deleting Web Push subscriptions: %w", err) + } + columns, err := stubbifyUser(user) if err != nil { return gtserror.Newf("error stubbifying user: %w", err) diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go index f0839f2f6..ad9d9b2ae 100644 --- a/internal/processing/admin/admin_test.go +++ b/internal/processing/admin/admin_test.go @@ -119,6 +119,7 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.mediaManager, &suite.state, suite.emailSender, + testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8dabfba96..0bba23089 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -39,6 +39,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/markers" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/processing/polls" + "github.com/superseriousbusiness/gotosocial/internal/processing/push" "github.com/superseriousbusiness/gotosocial/internal/processing/report" "github.com/superseriousbusiness/gotosocial/internal/processing/search" "github.com/superseriousbusiness/gotosocial/internal/processing/status" @@ -51,6 +52,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/subscriptions" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/webpush" ) // Processor groups together processing functions and @@ -88,6 +90,7 @@ type Processor struct { markers markers.Processor media media.Processor polls polls.Processor + push push.Processor report report.Processor search search.Processor status status.Processor @@ -146,6 +149,10 @@ func (p *Processor) Polls() *polls.Processor { return &p.polls } +func (p *Processor) Push() *push.Processor { + return &p.push +} + func (p *Processor) Report() *report.Processor { return &p.report } @@ -188,6 +195,7 @@ func NewProcessor( mediaManager *mm.Manager, state *state.State, emailSender email.Sender, + webPushSender webpush.Sender, visFilter *visibility.Filter, intFilter *interaction.Filter, ) *Processor { @@ -221,6 +229,7 @@ func NewProcessor( processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) processor.polls = polls.New(&common, state, converter) + processor.push = push.New(state, converter) processor.report = report.New(state, converter) processor.tags = tags.New(state, converter) processor.timeline = timeline.New(state, converter, visFilter) @@ -241,6 +250,7 @@ func NewProcessor( converter, visFilter, emailSender, + webPushSender, &processor.account, &processor.media, &processor.stream, diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index f152f3fad..84ab9ef48 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -135,6 +135,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.mediaManager, &suite.state, suite.emailSender, + testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) diff --git a/internal/processing/push/create.go b/internal/processing/push/create.go new file mode 100644 index 000000000..42a67dc19 --- /dev/null +++ b/internal/processing/push/create.go @@ -0,0 +1,65 @@ +// 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 push + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// CreateOrReplace creates a Web Push subscription for the given access token, +// or entirely replaces the previously existing subscription for that token. +func (p *Processor) CreateOrReplace( + ctx context.Context, + accountID string, + accessToken string, + request *apimodel.WebPushSubscriptionCreateRequest, +) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + // Clear any previous subscription. + if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil { + err := gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Insert a new one. + subscription := >smodel.WebPushSubscription{ + ID: id.NewULID(), + AccountID: accountID, + TokenID: tokenID, + Endpoint: request.Subscription.Endpoint, + Auth: request.Subscription.Keys.Auth, + P256dh: request.Subscription.Keys.P256dh, + NotificationFlags: alertsToNotificationFlags(request.Data.Alerts), + } + + if err := p.state.DB.PutWebPushSubscription(ctx, subscription); err != nil { + err := gtserror.Newf("couldn't create Web Push subscription for token ID %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/processing/push/delete.go b/internal/processing/push/delete.go new file mode 100644 index 000000000..6f5c61444 --- /dev/null +++ b/internal/processing/push/delete.go @@ -0,0 +1,39 @@ +// 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 push + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Delete deletes the Web Push subscription for the given access token, if there is one. +func (p *Processor) Delete(ctx context.Context, accessToken string) gtserror.WithCode { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return errWithCode + } + + if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil { + err := gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} diff --git a/internal/processing/push/get.go b/internal/processing/push/get.go new file mode 100644 index 000000000..542f08862 --- /dev/null +++ b/internal/processing/push/get.go @@ -0,0 +1,47 @@ +// 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 push + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Get returns the Web Push subscription for the given access token. +func (p *Processor) Get(ctx context.Context, accessToken string) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + if subscription == nil { + err := errors.New("no Web Push subscription exists for this access token") + return nil, gtserror.NewErrorNotFound(err) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/processing/push/push.go b/internal/processing/push/push.go new file mode 100644 index 000000000..f46280386 --- /dev/null +++ b/internal/processing/push/push.go @@ -0,0 +1,85 @@ +// 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 push + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { + state *state.State + converter *typeutils.Converter +} + +func New(state *state.State, converter *typeutils.Converter) Processor { + return Processor{ + state: state, + converter: converter, + } +} + +// getTokenID returns the token ID for a given access token. +// Since all push API calls require authentication, this should always be available. +func (p *Processor) getTokenID(ctx context.Context, accessToken string) (string, gtserror.WithCode) { + token, err := p.state.DB.GetTokenByAccess(ctx, accessToken) + if err != nil { + err := gtserror.Newf("couldn't find token ID for access token: %w", err) + return "", gtserror.NewErrorInternalError(err) + } + + return token.ID, nil +} + +// apiSubscription is a shortcut to return the API version of the given Web Push subscription, +// or return an appropriate error if conversion fails. +func (p *Processor) apiSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) (*apimodel.WebPushSubscription, gtserror.WithCode) { + apiSubscription, err := p.converter.WebPushSubscriptionToAPIWebPushSubscription(ctx, subscription) + if err != nil { + err := gtserror.Newf("error converting Web Push subscription %s to API representation: %w", subscription.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiSubscription, nil +} + +// alertsToNotificationFlags turns the alerts section of a push subscription API request into a packed bitfield. +func alertsToNotificationFlags(alerts *apimodel.WebPushSubscriptionAlerts) gtsmodel.WebPushSubscriptionNotificationFlags { + var n gtsmodel.WebPushSubscriptionNotificationFlags + + n.Set(gtsmodel.NotificationFollow, alerts.Follow) + n.Set(gtsmodel.NotificationFollowRequest, alerts.FollowRequest) + n.Set(gtsmodel.NotificationFavourite, alerts.Favourite) + n.Set(gtsmodel.NotificationMention, alerts.Mention) + n.Set(gtsmodel.NotificationReblog, alerts.Reblog) + n.Set(gtsmodel.NotificationPoll, alerts.Poll) + n.Set(gtsmodel.NotificationStatus, alerts.Status) + n.Set(gtsmodel.NotificationUpdate, alerts.Update) + n.Set(gtsmodel.NotificationAdminSignup, alerts.AdminSignup) + n.Set(gtsmodel.NotificationAdminReport, alerts.AdminReport) + n.Set(gtsmodel.NotificationPendingFave, alerts.PendingFavourite) + n.Set(gtsmodel.NotificationPendingReply, alerts.PendingReply) + n.Set(gtsmodel.NotificationPendingReblog, alerts.PendingReblog) + + return n +} diff --git a/internal/processing/push/update.go b/internal/processing/push/update.go new file mode 100644 index 000000000..370536f9b --- /dev/null +++ b/internal/processing/push/update.go @@ -0,0 +1,63 @@ +// 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 push + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Update updates the Web Push subscription for the given access token. +func (p *Processor) Update( + ctx context.Context, + accessToken string, + request *apimodel.WebPushSubscriptionUpdateRequest, +) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + // Get existing subscription. + subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + if subscription == nil { + err := errors.New("no Web Push subscription exists for this access token") + return nil, gtserror.NewErrorNotFound(err) + } + + // Update it. + subscription.NotificationFlags = alertsToNotificationFlags(request.Data.Alerts) + if err = p.state.DB.UpdateWebPushSubscription( + ctx, + subscription, + "notification_flags", + ); err != nil { + err := gtserror.Newf("couldn't update Web Push subscription for token ID %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index a242c7b74..09636e7eb 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -184,7 +184,7 @@ func (p *Processor) notifVisible( // If this is a new local account sign-up, // skip normal visibility checking because // origin account won't be confirmed yet. - if n.NotificationType == gtsmodel.NotificationSignup { + if n.NotificationType == gtsmodel.NotificationAdminSignup { return true, nil } diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index d955f0529..acb25673d 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -179,6 +179,28 @@ func (suite *FromClientAPITestSuite) checkStreamed( } } +// checkWebPushed asserts that the target account got a single Web Push notification with a given type. +func (suite *FromClientAPITestSuite) checkWebPushed( + sender *testrig.WebPushMockSender, + accountID string, + notificationType gtsmodel.NotificationType, +) { + pushedNotifications := sender.Sent[accountID] + if suite.Len(pushedNotifications, 1) { + pushedNotification := pushedNotifications[0] + suite.Equal(notificationType, pushedNotification.NotificationType) + } +} + +// checkNotWebPushed asserts that the target account got no Web Push notifications. +func (suite *FromClientAPITestSuite) checkNotWebPushed( + sender *testrig.WebPushMockSender, + accountID string, +) { + pushedNotifications := sender.Sent[accountID] + suite.Len(pushedNotifications, 0) +} + func (suite *FromClientAPITestSuite) statusJSON( ctx context.Context, typeConverter *typeutils.Converter, @@ -341,6 +363,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { string(notifJSON), stream.EventTypeNotification, ) + + // Check for a Web Push status notification. + suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationStatus) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { @@ -409,6 +434,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { statusJSON, stream.EventTypeUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { @@ -470,6 +498,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { suite.ErrorIs(err, db.ErrNoEntries) suite.Nil(notif) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { @@ -531,6 +562,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { suite.ErrorIs(err, db.ErrNoEntries) suite.Nil(notif) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyOK() { @@ -607,6 +641,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis statusJSON, stream.EventTypeUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyNo() { @@ -689,6 +726,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPolicyNone() { @@ -765,6 +805,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { @@ -829,6 +872,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { statusJSON, stream.EventTypeUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { @@ -981,6 +1027,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat conversationJSON, stream.EventTypeConversation, ) + + // Check for a Web Push mention notification. + suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationMention) } // A public message to a local user should not result in a conversation notification. @@ -1050,6 +1099,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate "", "", ) + + // Check for a Web Push mention notification. + suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationMention) } // A public status with a hashtag followed by a local user who does not otherwise follow the author @@ -1123,6 +1175,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag( "", stream.EventTypeUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A public status with a hashtag followed by a local user who does not otherwise follow the author @@ -1204,6 +1259,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagA "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A boost of a public status with a hashtag followed by a local user @@ -1306,6 +1364,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() "", stream.EventTypeUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A boost of a public status with a hashtag followed by a local user @@ -1416,6 +1477,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A boost of a public status with a hashtag followed by a local user @@ -1526,6 +1590,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list @@ -1598,6 +1665,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list @@ -1712,6 +1782,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv "", "", ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } // A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list @@ -1837,6 +1910,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv "", "", ) + + // Check for a Web Push status notification. + suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationStatus) } // Updating a public status with a hashtag followed by a local user who does not otherwise follow the author @@ -1910,6 +1986,9 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag( "", stream.EventTypeStatusUpdate, ) + + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { @@ -1963,6 +2042,9 @@ func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { stream.EventTypeDelete, ) + // Check for absence of Web Push notifications. + suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) + // Boost should no longer be in the database. if !testrig.WaitFor(func() bool { _, err := testStructs.State.DB.GetStatusByID(ctx, boostOfDeletedStatus.ID) diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go index 88d0e6071..70886d698 100644 --- a/internal/processing/workers/fromfediapi_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -240,7 +240,7 @@ func (suite *FromFediAPITestSuite) TestProcessFave() { notif := >smodel.Notification{} err = testStructs.State.DB.GetWhere(context.Background(), where, notif) suite.NoError(err) - suite.Equal(gtsmodel.NotificationFave, notif.NotificationType) + suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType) suite.Equal(fave.TargetAccountID, notif.TargetAccountID) suite.Equal(fave.AccountID, notif.OriginAccountID) suite.Equal(fave.StatusID, notif.StatusID) @@ -313,7 +313,7 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount( notif := >smodel.Notification{} err = testStructs.State.DB.GetWhere(context.Background(), where, notif) suite.NoError(err) - suite.Equal(gtsmodel.NotificationFave, notif.NotificationType) + suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType) suite.Equal(fave.TargetAccountID, notif.TargetAccountID) suite.Equal(fave.AccountID, notif.OriginAccountID) suite.Equal(fave.StatusID, notif.StatusID) diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go index 4f6597b9a..4dc58c433 100644 --- a/internal/processing/workers/surface.go +++ b/internal/processing/workers/surface.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/webpush" ) // Surface wraps functions for 'surfacing' the result @@ -38,5 +39,6 @@ type Surface struct { Stream *stream.Processor VisFilter *visibility.Filter EmailSender email.Sender + WebPushSender webpush.Sender Conversations *conversations.Processor } diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index 1520d2ec0..fdbd5e3c1 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -250,7 +250,7 @@ func (s *Surface) notifyFave( // notify status author // of fave by account. if err := s.Notify(ctx, - gtsmodel.NotificationFave, + gtsmodel.NotificationFavourite, fave.TargetAccount, fave.Account, fave.StatusID, @@ -521,7 +521,7 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro var errs gtserror.MultiError for _, mod := range modAccounts { if err := s.Notify(ctx, - gtsmodel.NotificationSignup, + gtsmodel.NotificationAdminSignup, mod, newUser.Account, "", @@ -647,5 +647,10 @@ func (s *Surface) Notify( } s.Stream.Notify(ctx, targetAccount, apiNotif) + // Send Web Push notification to the user. + if err = s.WebPushSender.Send(ctx, notif, filters, compiledMutes); err != nil { + return gtserror.Newf("error sending Web Push notifications: %w", err) + } + return nil } diff --git a/internal/processing/workers/surfacenotify_test.go b/internal/processing/workers/surfacenotify_test.go index 52ee89e8b..6444314e2 100644 --- a/internal/processing/workers/surfacenotify_test.go +++ b/internal/processing/workers/surfacenotify_test.go @@ -45,6 +45,7 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { Stream: testStructs.Processor.Stream(), VisFilter: visibility.NewFilter(testStructs.State), EmailSender: testStructs.EmailSender, + WebPushSender: testStructs.WebPushSender, Conversations: testStructs.Processor.Conversations(), } diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index ad673481b..9f37f554e 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/webpush" "github.com/superseriousbusiness/gotosocial/internal/workers" ) @@ -44,6 +45,7 @@ func New( converter *typeutils.Converter, visFilter *visibility.Filter, emailSender email.Sender, + webPushSender webpush.Sender, account *account.Processor, media *media.Processor, stream *stream.Processor, @@ -65,6 +67,7 @@ func New( Stream: stream, VisFilter: visFilter, EmailSender: emailSender, + WebPushSender: webPushSender, Conversations: conversations, } diff --git a/internal/text/substring.go b/internal/text/substring.go new file mode 100644 index 000000000..659b4ab50 --- /dev/null +++ b/internal/text/substring.go @@ -0,0 +1,45 @@ +// 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 text + +import ( + "github.com/rivo/uniseg" +) + +// FirstNBytesByWords produces a prefix substring of up to n bytes from a given string, respecting Unicode grapheme and +// word boundaries. The substring may be empty, and may include leading or trailing whitespace. +func FirstNBytesByWords(s string, n int) string { + substringEnd := 0 + + graphemes := uniseg.NewGraphemes(s) + for graphemes.Next() { + + if !graphemes.IsWordBoundary() { + continue + } + + _, end := graphemes.Positions() + if end > n { + break + } + + substringEnd = end + } + + return s[0:substringEnd] +} diff --git a/internal/text/substring_test.go b/internal/text/substring_test.go new file mode 100644 index 000000000..f85688218 --- /dev/null +++ b/internal/text/substring_test.go @@ -0,0 +1,47 @@ +// 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 text_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/text" +) + +type SubstringTestSuite struct { + suite.Suite +} + +func (suite *SubstringTestSuite) TestText() { + suite.Equal( + "Sphinx of black quartz, ", + text.FirstNBytesByWords("Sphinx of black quartz, judge my vow!", 25), + ) +} + +func (suite *SubstringTestSuite) TestEmoji() { + suite.Equal( + "🏳️⚧️ ", + text.FirstNBytesByWords("🏳️⚧️ 🙈", 20), + ) +} + +func TestSubstringTestSuite(t *testing.T) { + suite.Run(t, new(SubstringTestSuite)) +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go index c51c0755f..61df16e52 100644 --- a/internal/transport/transport_test.go +++ b/internal/transport/transport_test.go @@ -89,7 +89,13 @@ func (suite *TransportTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../testrig/media")), suite.mediaManager) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.processor = testrig.NewTestProcessor( + &suite.state, + suite.federator, + suite.emailSender, + testrig.NewNoopWebPushSender(), + suite.mediaManager, + ) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../testrig/media") diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index a5fe5201b..3ed2828f2 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -540,8 +540,9 @@ func (suite *TypeUtilsTestSuite) GetProcessor() *processing.Processor { mediaManager := testrig.NewTestMediaManager(&suite.state) federator := testrig.NewTestFederator(&suite.state, transportController, mediaManager) emailSender := testrig.NewEmailSender("../../web/template/", nil) + webPushSender := testrig.NewNoopWebPushSender() - processor := testrig.NewTestProcessor(&suite.state, federator, emailSender, mediaManager) + processor := testrig.NewTestProcessor(&suite.state, federator, emailSender, webPushSender, mediaManager) testrig.StartWorkers(&suite.state, processor.Workers()) return processor diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index cdc250f98..6739d0540 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -616,6 +616,11 @@ func (c *Converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac } func (c *Converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) { + vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx) + if err != nil { + return nil, gtserror.Newf("error getting VAPID public key: %w", err) + } + return &apimodel.Application{ ID: a.ID, Name: a.Name, @@ -623,6 +628,7 @@ func (c *Converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Applic RedirectURI: a.RedirectURI, ClientID: a.ClientID, ClientSecret: a.ClientSecret, + VapidKey: vapidKeyPair.Public, }, nil } @@ -1878,6 +1884,12 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) // #nosec G115 -- Already validated. instance.Configuration.OIDCEnabled = config.GetOIDCEnabled() + vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx) + if err != nil { + return nil, gtserror.Newf("error getting VAPID public key: %w", err) + } + instance.Configuration.VAPID.PublicKey = vapidKeyPair.Public + // registrations instance.Registrations.Enabled = config.GetAccountsRegistrationOpen() instance.Registrations.ApprovalRequired = true // always required @@ -2985,3 +2997,36 @@ func (c *Converter) InteractionReqToAPIInteractionReq( URI: req.URI, }, nil } + +func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription( + ctx context.Context, + subscription *gtsmodel.WebPushSubscription, +) (*apimodel.WebPushSubscription, error) { + vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx) + if err != nil { + return nil, gtserror.Newf("error getting VAPID key pair: %w", err) + } + + return &apimodel.WebPushSubscription{ + ID: subscription.ID, + Endpoint: subscription.Endpoint, + ServerKey: vapidKeyPair.Public, + Alerts: apimodel.WebPushSubscriptionAlerts{ + Follow: subscription.NotificationFlags.Get(gtsmodel.NotificationFollow), + FollowRequest: subscription.NotificationFlags.Get(gtsmodel.NotificationFollowRequest), + Favourite: subscription.NotificationFlags.Get(gtsmodel.NotificationFavourite), + Mention: subscription.NotificationFlags.Get(gtsmodel.NotificationMention), + Reblog: subscription.NotificationFlags.Get(gtsmodel.NotificationReblog), + Poll: subscription.NotificationFlags.Get(gtsmodel.NotificationPoll), + Status: subscription.NotificationFlags.Get(gtsmodel.NotificationStatus), + Update: subscription.NotificationFlags.Get(gtsmodel.NotificationUpdate), + AdminSignup: subscription.NotificationFlags.Get(gtsmodel.NotificationAdminSignup), + AdminReport: subscription.NotificationFlags.Get(gtsmodel.NotificationAdminReport), + PendingFavourite: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingFave), + PendingReply: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReply), + PendingReblog: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReblog), + }, + Policy: apimodel.WebPushNotificationPolicyAll, + Standard: true, + }, nil +} diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 005abf4eb..1ca0840a5 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -21,6 +21,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" "github.com/stretchr/testify/suite" @@ -2061,6 +2062,13 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() { b, err := json.MarshalIndent(instance, "", " ") suite.NoError(err) + // The VAPID public key changes from run to run. + vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx) + if err != nil { + suite.FailNow(err.Error()) + } + s := strings.Replace(string(b), vapidKeyPair.Public, "VAPID_PUBLIC_KEY_PLACEHOLDER", 1) + suite.Equal(`{ "domain": "localhost:8080", "account_domain": "localhost:8080", @@ -2140,6 +2148,9 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() { }, "emojis": { "emoji_size_limit": 51200 + }, + "vapid": { + "public_key": "VAPID_PUBLIC_KEY_PLACEHOLDER" } }, "registrations": { @@ -2184,7 +2195,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() { "rules": [], "terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e", "terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible." -}`, string(b)) +}`, s) } func (suite *InternalToFrontendTestSuite) TestEmojiToFrontend() { 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, + ) +} diff --git a/internal/workers/workers.go b/internal/workers/workers.go index 4cf549041..50ad3cce5 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -54,6 +54,10 @@ type Workers struct { // eg., import tasks, admin tasks. Processing FnWorkerPool + // WebPush provides a worker pool for + // delivering Web Push notifications. + WebPush FnWorkerPool + // prevent pass-by-value. _ nocopy } @@ -90,6 +94,10 @@ func (w *Workers) Start() { n = maxprocs w.Processing.Start(n) log.Infof(nil, "started %d processing workers", n) + + n = maxprocs + w.WebPush.Start(n) + log.Infof(nil, "started %d Web Push workers", n) } // Stop will stop all of the contained @@ -113,6 +121,9 @@ func (w *Workers) Stop() { w.Processing.Stop() log.Info(nil, "stopped processing workers") + + w.WebPush.Stop() + log.Info(nil, "stopped WebPush workers") } // nocopy when embedded will signal linter to |