diff options
Diffstat (limited to 'internal/api')
45 files changed, 1952 insertions, 82 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), ) |