diff --git a/internal/api/client.go b/internal/api/client.go index 77a63eb89..23a5803fc 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -46,6 +46,7 @@ "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" @@ -89,6 +90,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 @@ -140,6 +142,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) @@ -179,6 +182,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/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 . + +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..356339286 --- /dev/null +++ b/internal/api/client/push/push_test.go @@ -0,0 +1,111 @@ +// 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 . + +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/internal/webpush" + "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, + webpush.NewNoopSender(), + 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 . + +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 . + +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..24105b228 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionget.go @@ -0,0 +1,72 @@ +// 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 . + +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': +// name: pushSubscription +// description: Push subscription for current access token. +// schema: +// "$ref": "#/definitions/pushSubscription" +// '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 . + +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..dd565230c --- /dev/null +++ b/internal/api/client/push/pushsubscriptionpost.go @@ -0,0 +1,288 @@ +// 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 . + +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" +) + +// TODO: (Vyr) real parameters + +// PushSubscriptionPOSTHandler swagger:operation POST /api/v1/push/subscription pushSubscriptionPost +// +// Get the push subscription +// +// --- +// 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': +// name: pushSubscription +// description: Push subscription for current auth token. +// schema: +// "$ref": "#/definitions/pushSubscription" +// '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") + } + // TODO: (Vyr) remove http option after testing + if endpointUrl.Scheme != "https" && endpointUrl.Scheme != "http" { + 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 . + +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..6874d7796 --- /dev/null +++ b/internal/api/client/push/pushsubscriptionput.go @@ -0,0 +1,233 @@ +// 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 . + +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': +// name: pushSubscription +// description: Push subscription for current auth token. +// schema: +// "$ref": "#/definitions/pushSubscription" +// '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 . + +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/model/pushsubscription.go b/internal/api/model/pushsubscription.go deleted file mode 100644 index 37e223779..000000000 --- a/internal/api/model/pushsubscription.go +++ /dev/null @@ -1,58 +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 . - -package model - -// PushSubscription represents a subscription to the push streaming server. -// -// swagger:model pushSubscription -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 public 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. -// -// swagger:model pushSubscriptionAlerts -type PushSubscriptionAlerts struct { - // Receive a push notification when someone has followed you? - Follow bool `json:"follow"` - // Receive a push notification when someone has requested to followed 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"` -} diff --git a/internal/api/model/pushnotification.go b/internal/api/model/webpushnotification.go similarity index 87% rename from internal/api/model/pushnotification.go rename to internal/api/model/webpushnotification.go index 602e3d20c..5d7a593fc 100644 --- a/internal/api/model/pushnotification.go +++ b/internal/api/model/webpushnotification.go @@ -17,12 +17,12 @@ package model -// PushNotification represents a notification summary delivered to the client by the Web Push server. +// 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. +// It is not used in the client API directly, but is included in the API doc for decoding Web Push notifications. // -// swagger:model pushNotification -type PushNotification struct { +// swagger:model webPushNotification +type WebPushNotification struct { // NotificationID is the Notification.ID of the referenced Notification. NotificationID string `json:"notification_id"` diff --git a/internal/api/model/webpushsubscription.go b/internal/api/model/webpushsubscription.go new file mode 100644 index 000000000..96f86963c --- /dev/null +++ b/internal/api/model/webpushsubscription.go @@ -0,0 +1,142 @@ +// 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 . + +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"` +} + +// 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"` +} diff --git a/internal/db/bundb/webpush.go b/internal/db/bundb/webpush.go index bb2ee2ba2..3f215c9c4 100644 --- a/internal/db/bundb/webpush.go +++ b/internal/db/bundb/webpush.go @@ -5,6 +5,7 @@ "errors" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util/xslices" @@ -54,7 +55,7 @@ func (w *webPushDB) PutVAPIDKeyPair(ctx context.Context, vapidKeyPair *gtsmodel. } func (w *webPushDB) GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) { - return w.state.Caches.DB.WebPushSubscription.LoadOne( + subscription, err := w.state.Caches.DB.WebPushSubscription.LoadOne( "TokenID", func() (*gtsmodel.WebPushSubscription, error) { var subscription gtsmodel.WebPushSubscription @@ -67,6 +68,10 @@ func() (*gtsmodel.WebPushSubscription, error) { }, tokenID, ) + if err != nil { + return nil, err + } + return subscription, nil } func (w *webPushDB) PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error { @@ -85,15 +90,22 @@ func (w *webPushDB) UpdateWebPushSubscription(ctx context.Context, subscription } // Update database. - if _, err := w.db. + result, err := w.db. NewUpdate(). Model(subscription). Column(columns...). Where("? = ?", bun.Ident("id"), subscription.ID). - Exec(ctx); // nocollapse - err != nil { + Exec(ctx) + if err != nil { return err } + rowsAffected, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting updated row count: %w", err) + } + if rowsAffected == 0 { + return db.ErrNoEntries + } // Update cache. w.state.Caches.DB.WebPushSubscription.Put(subscription) diff --git a/internal/db/webpush.go b/internal/db/webpush.go index 6752657d7..05c76e0d5 100644 --- a/internal/db/webpush.go +++ b/internal/db/webpush.go @@ -33,13 +33,15 @@ type WebPush interface { // This should be called at most once, during server startup. PutVAPIDKeyPair(ctx context.Context, vapidKeyPair *gtsmodel.VAPIDKeyPair) error - // GetWebPushSubscriptionByTokenID retrieves an access token's Web Push subscription, if there is one. + // GetWebPushSubscriptionByTokenID retrieves an access token's Web Push subscription. + // There may not be one, in which case an error will be returned. GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) // PutWebPushSubscription creates an access token's Web Push subscription. PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error // UpdateWebPushSubscription updates an access token's Web Push subscription. + // There may not be one, in which case an error will be returned. UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error // DeleteWebPushSubscriptionByTokenID deletes an access token's Web Push subscription, if there is one. diff --git a/internal/gtsmodel/webpushsubscription.go b/internal/gtsmodel/webpushsubscription.go index b14fb1caf..ce1ae0a09 100644 --- a/internal/gtsmodel/webpushsubscription.go +++ b/internal/gtsmodel/webpushsubscription.go @@ -51,17 +51,17 @@ type WebPushSubscription struct { // NotifyFollow and friends control which notifications are delivered to a given subscription. // Corresponds to NotificationType and model.PushSubscriptionAlerts. - NotifyFollow *bool `bun:",nullzero,notnull,default:false"` - NotifyFollowRequest *bool `bun:",nullzero,notnull,default:false"` - NotifyFavourite *bool `bun:",nullzero,notnull,default:false"` - NotifyMention *bool `bun:",nullzero,notnull,default:false"` - NotifyReblog *bool `bun:",nullzero,notnull,default:false"` - NotifyPoll *bool `bun:",nullzero,notnull,default:false"` - NotifyStatus *bool `bun:",nullzero,notnull,default:false"` - NotifyUpdate *bool `bun:",nullzero,notnull,default:false"` - NotifyAdminSignup *bool `bun:",nullzero,notnull,default:false"` - NotifyAdminReport *bool `bun:",nullzero,notnull,default:false"` - NotifyPendingFave *bool `bun:",nullzero,notnull,default:false"` - NotifyPendingReply *bool `bun:",nullzero,notnull,default:false"` - NotifyPendingReblog *bool `bun:",nullzero,notnull,default:false"` + NotifyFollow *bool `bun:",nullzero,notnull,default:false"` + NotifyFollowRequest *bool `bun:",nullzero,notnull,default:false"` + NotifyFavourite *bool `bun:",nullzero,notnull,default:false"` + NotifyMention *bool `bun:",nullzero,notnull,default:false"` + NotifyReblog *bool `bun:",nullzero,notnull,default:false"` + NotifyPoll *bool `bun:",nullzero,notnull,default:false"` + NotifyStatus *bool `bun:",nullzero,notnull,default:false"` + NotifyUpdate *bool `bun:",nullzero,notnull,default:false"` + NotifyAdminSignup *bool `bun:",nullzero,notnull,default:false"` + NotifyAdminReport *bool `bun:",nullzero,notnull,default:false"` + NotifyPendingFavourite *bool `bun:",nullzero,notnull,default:false"` + NotifyPendingReply *bool `bun:",nullzero,notnull,default:false"` + NotifyPendingReblog *bool `bun:",nullzero,notnull,default:false"` } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 209129f8b..eebead905 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -39,6 +39,7 @@ "github.com/superseriousbusiness/gotosocial/internal/processing/markers" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/processing/polls" + "github.com/superseriousbusiness/gotosocial/internal/processing/push" "github.com/superseriousbusiness/gotosocial/internal/processing/report" "github.com/superseriousbusiness/gotosocial/internal/processing/search" "github.com/superseriousbusiness/gotosocial/internal/processing/status" @@ -88,6 +89,7 @@ type Processor struct { markers markers.Processor media media.Processor polls polls.Processor + push push.Processor report report.Processor search search.Processor status status.Processor @@ -146,6 +148,10 @@ func (p *Processor) Polls() *polls.Processor { return &p.polls } +func (p *Processor) Push() *push.Processor { + return &p.push +} + func (p *Processor) Report() *report.Processor { return &p.report } @@ -221,6 +227,7 @@ func NewProcessor( processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) processor.polls = polls.New(&common, state, converter) + processor.push = push.New(state, converter) processor.report = report.New(state, converter) processor.tags = tags.New(state, converter) processor.timeline = timeline.New(state, converter, visFilter) diff --git a/internal/processing/push/create.go b/internal/processing/push/create.go new file mode 100644 index 000000000..14819a9c9 --- /dev/null +++ b/internal/processing/push/create.go @@ -0,0 +1,78 @@ +// 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 . + +package push + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// CreateOrReplace creates a Web Push subscription for the given access token, +// or entirely replaces the previously existing subscription for that token. +func (p *Processor) CreateOrReplace( + ctx context.Context, + accountID string, + accessToken string, + request *apimodel.WebPushSubscriptionCreateRequest, +) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + // Clear any previous subscription. + if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + + // Insert a new one. + subscription := >smodel.WebPushSubscription{ + ID: id.NewULID(), + AccountID: accountID, + TokenID: tokenID, + Endpoint: request.Subscription.Endpoint, + Auth: request.Subscription.Keys.Auth, + P256dh: request.Subscription.Keys.P256dh, + NotifyFollow: &request.Data.Alerts.Follow, + NotifyFollowRequest: &request.Data.Alerts.FollowRequest, + NotifyFavourite: &request.Data.Alerts.Favourite, + NotifyMention: &request.Data.Alerts.Mention, + NotifyReblog: &request.Data.Alerts.Reblog, + NotifyPoll: &request.Data.Alerts.Poll, + NotifyStatus: &request.Data.Alerts.Status, + NotifyUpdate: &request.Data.Alerts.Update, + NotifyAdminSignup: &request.Data.Alerts.AdminSignup, + NotifyAdminReport: &request.Data.Alerts.AdminReport, + NotifyPendingFavourite: &request.Data.Alerts.PendingFavourite, + NotifyPendingReply: &request.Data.Alerts.PendingReply, + NotifyPendingReblog: &request.Data.Alerts.PendingReblog, + } + if err := p.state.DB.PutWebPushSubscription(ctx, subscription); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("couldn't create Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/processing/push/delete.go b/internal/processing/push/delete.go new file mode 100644 index 000000000..48d25a42f --- /dev/null +++ b/internal/processing/push/delete.go @@ -0,0 +1,40 @@ +// 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 . + +package push + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Delete deletes the Web Push subscription for the given access token, if there is one. +func (p *Processor) Delete(ctx context.Context, accessToken string) gtserror.WithCode { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return errWithCode + } + + if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil { + return gtserror.NewErrorInternalError( + gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + + return nil +} diff --git a/internal/processing/push/get.go b/internal/processing/push/get.go new file mode 100644 index 000000000..2f19ae223 --- /dev/null +++ b/internal/processing/push/get.go @@ -0,0 +1,47 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package push + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Get returns the Web Push subscription for the given access token. +func (p *Processor) Get(ctx context.Context, accessToken string) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + if subscription == nil { + return nil, gtserror.NewErrorNotFound(errors.New("no Web Push subscription exists for this access token")) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/processing/push/push.go b/internal/processing/push/push.go new file mode 100644 index 000000000..9d314a243 --- /dev/null +++ b/internal/processing/push/push.go @@ -0,0 +1,66 @@ +// 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 . + +package push + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { + state *state.State + converter *typeutils.Converter +} + +func New(state *state.State, converter *typeutils.Converter) Processor { + return Processor{ + state: state, + converter: converter, + } +} + +// getTokenID returns the token ID for a given access token. +// Since all push API calls require authentication, this should always be available. +func (p *Processor) getTokenID(ctx context.Context, accessToken string) (string, gtserror.WithCode) { + token, err := p.state.DB.GetTokenByAccess(ctx, accessToken) + if err != nil { + return "", gtserror.NewErrorInternalError( + gtserror.Newf("couldn't find token ID for access token: %w", err), + ) + } + + return token.ID, nil +} + +// apiSubscription is a shortcut to return the API version of the given Web Push subscription, +// or return an appropriate error if conversion fails. +func (p *Processor) apiSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) (*apimodel.WebPushSubscription, gtserror.WithCode) { + apiSubscription, err := p.converter.WebPushSubscriptionToAPIWebPushSubscription(ctx, subscription) + if err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("error converting Web Push subscription %s to API representation: %w", subscription.ID, err), + ) + } + + return apiSubscription, nil +} diff --git a/internal/processing/push/update.go b/internal/processing/push/update.go new file mode 100644 index 000000000..7ab0fc7a7 --- /dev/null +++ b/internal/processing/push/update.go @@ -0,0 +1,88 @@ +// 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 . + +package push + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// Update updates the Web Push subscription for the given access token. +func (p *Processor) Update( + ctx context.Context, + accessToken string, + request *apimodel.WebPushSubscriptionUpdateRequest, +) (*apimodel.WebPushSubscription, gtserror.WithCode) { + tokenID, errWithCode := p.getTokenID(ctx, accessToken) + if errWithCode != nil { + return nil, errWithCode + } + + // Get existing subscription. + subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + if subscription == nil { + return nil, gtserror.NewErrorNotFound(errors.New("no Web Push subscription exists for this access token")) + } + + // Update it. + subscription.NotifyFollow = &request.Data.Alerts.Follow + subscription.NotifyFollowRequest = &request.Data.Alerts.FollowRequest + subscription.NotifyFavourite = &request.Data.Alerts.Favourite + subscription.NotifyMention = &request.Data.Alerts.Mention + subscription.NotifyReblog = &request.Data.Alerts.Reblog + subscription.NotifyPoll = &request.Data.Alerts.Poll + subscription.NotifyStatus = &request.Data.Alerts.Status + subscription.NotifyUpdate = &request.Data.Alerts.Update + subscription.NotifyAdminSignup = &request.Data.Alerts.AdminSignup + subscription.NotifyAdminReport = &request.Data.Alerts.AdminReport + subscription.NotifyPendingFavourite = &request.Data.Alerts.PendingFavourite + subscription.NotifyPendingReply = &request.Data.Alerts.PendingReply + subscription.NotifyPendingReblog = &request.Data.Alerts.PendingReblog + if err = p.state.DB.UpdateWebPushSubscription( + ctx, + subscription, + "notify_follow", + "notify_follow_request", + "notify_favourite", + "notify_mention", + "notify_reblog", + "notify_poll", + "notify_status", + "notify_update", + "notify_admin_signup", + "notify_admin_report", + "notify_pending_favourite", + "notify_pending_reply", + "notify_pending_reblog", + ); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("couldn't update Web Push subscription for token ID %s: %w", tokenID, err), + ) + } + + return p.apiSubscription(ctx, subscription) +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index ba6b40f8f..a381b1f68 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -2816,3 +2816,34 @@ func (c *Converter) InteractionReqToAPIInteractionReq( URI: req.URI, }, nil } + +func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription( + ctx context.Context, + subscription *gtsmodel.WebPushSubscription, +) (*apimodel.WebPushSubscription, error) { + vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx) + if err != nil { + return nil, gtserror.Newf("error getting VAPID key pair: %w", err) + } + + return &apimodel.WebPushSubscription{ + ID: subscription.ID, + Endpoint: subscription.Endpoint, + ServerKey: vapidKeyPair.Public, + Alerts: apimodel.WebPushSubscriptionAlerts{ + Follow: *subscription.NotifyFollow, + FollowRequest: *subscription.NotifyFollowRequest, + Favourite: *subscription.NotifyFavourite, + Mention: *subscription.NotifyMention, + Reblog: *subscription.NotifyReblog, + Poll: *subscription.NotifyPoll, + Status: *subscription.NotifyStatus, + Update: *subscription.NotifyUpdate, + AdminSignup: *subscription.NotifyAdminSignup, + AdminReport: *subscription.NotifyAdminReport, + PendingFavourite: *subscription.NotifyPendingFavourite, + PendingReply: *subscription.NotifyPendingReply, + PendingReblog: *subscription.NotifyPendingReblog, + }, + }, nil +} diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go index 21a0bdba8..76661068f 100644 --- a/internal/webpush/realsender.go +++ b/internal/webpush/realsender.go @@ -101,7 +101,7 @@ func (r *realSender) Send( case gtsmodel.NotificationAdminReport: notify = *subscription.NotifyAdminReport case gtsmodel.NotificationPendingFave: - notify = *subscription.NotifyPendingFave + notify = *subscription.NotifyPendingFavourite case gtsmodel.NotificationPendingReply: notify = *subscription.NotifyPendingReply case gtsmodel.NotificationPendingReblog: @@ -174,7 +174,7 @@ func (r *realSender) sendToSubscription( } // Create push notification payload struct. - pushNotification := &apimodel.PushNotification{ + pushNotification := &apimodel.WebPushNotification{ NotificationID: apiNotification.ID, NotificationType: apiNotification.Type, Icon: apiNotification.Account.Avatar, diff --git a/testrig/testmodels.go b/testrig/testmodels.go index c9c0c7be5..2d381c888 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -3479,25 +3479,25 @@ func NewTestUserMutes() map[string]*gtsmodel.UserMute { func NewTestWebPushSubscriptions() map[string]*gtsmodel.WebPushSubscription { return map[string]*gtsmodel.WebPushSubscription{ "local_account_1_token_1": { - ID: "01G65Z755AFWAKHE12NY0CQ9FH", - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", - TokenID: "01F8MGTQW4DKTDF8SW5CT9HYGA", - Endpoint: "https://example.test/push", - Auth: "cgna/fzrYLDQyPf5hD7IsA==", - P256dh: "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=", - NotifyFollow: util.Ptr(true), - NotifyFollowRequest: util.Ptr(true), - NotifyFavourite: util.Ptr(true), - NotifyMention: util.Ptr(true), - NotifyReblog: util.Ptr(true), - NotifyPoll: util.Ptr(true), - NotifyStatus: util.Ptr(true), - NotifyUpdate: util.Ptr(true), - NotifyAdminSignup: util.Ptr(true), - NotifyAdminReport: util.Ptr(true), - NotifyPendingFave: util.Ptr(true), - NotifyPendingReply: util.Ptr(true), - NotifyPendingReblog: util.Ptr(true), + ID: "01G65Z755AFWAKHE12NY0CQ9FH", + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + TokenID: "01F8MGTQW4DKTDF8SW5CT9HYGA", + Endpoint: "https://example.test/push", + Auth: "cgna/fzrYLDQyPf5hD7IsA==", + P256dh: "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY=", + NotifyFollow: util.Ptr(true), + NotifyFollowRequest: util.Ptr(true), + NotifyFavourite: util.Ptr(true), + NotifyMention: util.Ptr(true), + NotifyReblog: util.Ptr(true), + NotifyPoll: util.Ptr(true), + NotifyStatus: util.Ptr(true), + NotifyUpdate: util.Ptr(true), + NotifyAdminSignup: util.Ptr(true), + NotifyAdminReport: util.Ptr(true), + NotifyPendingFavourite: util.Ptr(true), + NotifyPendingReply: util.Ptr(true), + NotifyPendingReblog: util.Ptr(true), }, } }