From bcda048eab799284fc46d74706334bf9ef76dc83 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:43:25 +0200 Subject: [feature] Self-serve email change for users (#2957) * [feature] Email change * frontend stuff for changing email * docs * tests etc * differentiate more clearly between local user+account and account * populate user --- internal/api/activitypub/users/userget_test.go | 2 +- internal/api/client/accounts/accountcreate.go | 6 +- internal/api/client/accounts/accountdelete.go | 2 +- internal/api/client/admin/accountapprove.go | 2 +- internal/api/client/admin/accountreject.go | 2 +- internal/api/client/user/emailchange.go | 104 ++++++++++++++ internal/api/client/user/emailchange_test.go | 142 +++++++++++++++++++ internal/api/client/user/passwordchange_test.go | 122 +++++------------ internal/api/client/user/user.go | 4 + internal/api/client/user/user_test.go | 43 +++++- internal/api/client/user/userget.go | 78 +++++++++++ internal/api/model/user.go | 61 +++++++++ internal/email/confirm.go | 15 +- internal/email/email_test.go | 17 ++- internal/federation/federatingdb/delete.go | 2 +- internal/federation/federatingdb/move.go | 2 +- internal/federation/federatingdb/move_test.go | 6 +- internal/federation/federatingdb/update.go | 2 +- internal/processing/account/account.go | 4 - internal/processing/account/account_test.go | 5 +- internal/processing/account/create.go | 165 ---------------------- internal/processing/account/delete.go | 17 --- internal/processing/account/update.go | 2 +- internal/processing/account/update_test.go | 10 +- internal/processing/account_test.go | 106 -------------- internal/processing/admin/accountapprove.go | 79 ----------- internal/processing/admin/accountapprove_test.go | 75 ---------- internal/processing/admin/accountreject.go | 113 --------------- internal/processing/admin/accountreject_test.go | 142 ------------------- internal/processing/admin/signupapprove.go | 82 +++++++++++ internal/processing/admin/signupapprove_test.go | 75 ++++++++++ internal/processing/admin/signupreject.go | 116 ++++++++++++++++ internal/processing/admin/signupreject_test.go | 142 +++++++++++++++++++ internal/processing/processor.go | 6 +- internal/processing/report/create.go | 2 +- internal/processing/user/create.go | 167 +++++++++++++++++++++++ internal/processing/user/delete.go | 48 +++++++ internal/processing/user/email.go | 81 +++++++++++ internal/processing/user/get.go | 32 +++++ internal/processing/user/user.go | 14 +- internal/processing/user/user_test.go | 3 +- internal/processing/workers/fromclientapi.go | 66 +++++---- internal/processing/workers/fromfediapi.go | 12 +- internal/processing/workers/fromfediapi_test.go | 4 +- internal/processing/workers/surfaceemail.go | 6 +- internal/typeutils/internaltofrontend.go | 38 ++++++ internal/web/signup.go | 4 +- 47 files changed, 1365 insertions(+), 863 deletions(-) create mode 100644 internal/api/client/user/emailchange.go create mode 100644 internal/api/client/user/emailchange_test.go create mode 100644 internal/api/client/user/userget.go delete mode 100644 internal/processing/account/create.go delete mode 100644 internal/processing/account_test.go delete mode 100644 internal/processing/admin/accountapprove.go delete mode 100644 internal/processing/admin/accountapprove_test.go delete mode 100644 internal/processing/admin/accountreject.go delete mode 100644 internal/processing/admin/accountreject_test.go create mode 100644 internal/processing/admin/signupapprove.go create mode 100644 internal/processing/admin/signupapprove_test.go create mode 100644 internal/processing/admin/signupreject.go create mode 100644 internal/processing/admin/signupreject_test.go create mode 100644 internal/processing/user/create.go create mode 100644 internal/processing/user/delete.go create mode 100644 internal/processing/user/get.go (limited to 'internal') diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go index ac8b2c0eb..2a68b309c 100644 --- a/internal/api/activitypub/users/userget_test.go +++ b/internal/api/activitypub/users/userget_test.go @@ -97,7 +97,7 @@ func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { userModule := users.New(suite.processor) targetAccount := suite.testAccounts["local_account_1"] - suite.processor.Account().DeleteSelf(context.Background(), suite.testAccounts["local_account_1"]) + suite.processor.User().DeleteSelf(context.Background(), suite.testAccounts["local_account_1"]) // wait for the account delete to be processed if !testrig.WaitFor(func() bool { diff --git a/internal/api/client/accounts/accountcreate.go b/internal/api/client/accounts/accountcreate.go index 920b6d4d8..33d743791 100644 --- a/internal/api/client/accounts/accountcreate.go +++ b/internal/api/client/accounts/accountcreate.go @@ -105,9 +105,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { } form.IP = signUpIP - // Create the new account + user. + // Create the new user+account. ctx := c.Request.Context() - user, errWithCode := m.processor.Account().Create( + user, errWithCode := m.processor.User().Create( ctx, authed.Application, form, @@ -118,7 +118,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { } // Get a token for the new user. - ti, errWithCode := m.processor.Account().TokenForNewUser( + ti, errWithCode := m.processor.User().TokenForNewUser( ctx, authed.Token, authed.Application, diff --git a/internal/api/client/accounts/accountdelete.go b/internal/api/client/accounts/accountdelete.go index 947634f70..9a1ef7931 100644 --- a/internal/api/client/accounts/accountdelete.go +++ b/internal/api/client/accounts/accountdelete.go @@ -91,7 +91,7 @@ func (m *Module) AccountDeletePOSTHandler(c *gin.Context) { return } - if errWithCode := m.processor.Account().DeleteSelf(c.Request.Context(), authed.Account); errWithCode != nil { + if errWithCode := m.processor.User().DeleteSelf(c.Request.Context(), authed.Account); errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/admin/accountapprove.go b/internal/api/client/admin/accountapprove.go index ff6474adb..7aaa48509 100644 --- a/internal/api/client/admin/accountapprove.go +++ b/internal/api/client/admin/accountapprove.go @@ -91,7 +91,7 @@ func (m *Module) AccountApprovePOSTHandler(c *gin.Context) { return } - account, errWithCode := m.processor.Admin().AccountApprove( + account, errWithCode := m.processor.Admin().SignupApprove( c.Request.Context(), authed.Account, targetAcctID, diff --git a/internal/api/client/admin/accountreject.go b/internal/api/client/admin/accountreject.go index 1e909b508..a4653985d 100644 --- a/internal/api/client/admin/accountreject.go +++ b/internal/api/client/admin/accountreject.go @@ -119,7 +119,7 @@ func (m *Module) AccountRejectPOSTHandler(c *gin.Context) { return } - account, errWithCode := m.processor.Admin().AccountReject( + account, errWithCode := m.processor.Admin().SignupReject( c.Request.Context(), authed.Account, targetAcctID, diff --git a/internal/api/client/user/emailchange.go b/internal/api/client/user/emailchange.go new file mode 100644 index 000000000..b2e25343f --- /dev/null +++ b/internal/api/client/user/emailchange.go @@ -0,0 +1,104 @@ +// 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 user + +import ( + "errors" + "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" +) + +// EmailChangePOSTHandler swagger:operation POST /api/v1/user/email_change userEmailChange +// +// Request changing the email address of authenticated user. +// +// --- +// tags: +// - user +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:user +// +// responses: +// '202': +// description: "Accepted: email change is processing; check your inbox to confirm new address." +// schema: +// "$ref": "#/definitions/user" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: "Conflict: desired email address already in use" +// '500': +// description: internal error +func (m *Module) EmailChangePOSTHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := &apimodel.EmailChangeRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if form.Password == "" { + err := errors.New("email change request missing field password") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + user, errWithCode := m.processor.User().EmailChange( + c.Request.Context(), + authed.User, + form.Password, + form.NewEmail, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusAccepted, user) +} diff --git a/internal/api/client/user/emailchange_test.go b/internal/api/client/user/emailchange_test.go new file mode 100644 index 000000000..fce96c144 --- /dev/null +++ b/internal/api/client/user/emailchange_test.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 user_test + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/user" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmailChangeTestSuite struct { + UserStandardTestSuite +} + +func (suite *EmailChangeTestSuite) TestEmailChangePOST() { + // Get a new processor for this test, as + // we're expecting an email, and we don't + // want the other tests interfering if + // we're running them at the same time. + state := new(state.State) + state.DB = testrig.NewTestDB(&suite.state) + storage := testrig.NewInMemoryStorage() + sentEmails := make(map[string]string) + emailSender := testrig.NewEmailSender("../../../../web/template/", sentEmails) + processor := testrig.NewTestProcessor(state, suite.federator, emailSender, suite.mediaManager) + testrig.StartWorkers(state, processor.Workers()) + userModule := user.New(processor) + testrig.StandardDBSetup(state.DB, suite.testAccounts) + testrig.StandardStorageSetup(storage, "../../../../testrig/media") + + defer func() { + testrig.StandardDBTeardown(state.DB) + testrig.StandardStorageTeardown(storage) + testrig.StopWorkers(state) + }() + + response, code := suite.POST(user.EmailChangePath, map[string][]string{ + "password": {"password"}, + "new_email": {"someone@example.org"}, + }, userModule.EmailChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusAccepted, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + apiUser := new(apimodel.User) + if err := json.Unmarshal(b, apiUser); err != nil { + suite.FailNow(err.Error()) + } + + // Unconfirmed email should be set now. + suite.Equal("someone@example.org", apiUser.UnconfirmedEmail) + + // Ensure unconfirmed address gets an email. + if !testrig.WaitFor(func() bool { + _, ok := sentEmails["someone@example.org"] + return ok + }) { + suite.FailNow("no email received") + } +} + +func (suite *EmailChangeTestSuite) TestEmailChangePOSTAddressInUse() { + response, code := suite.POST(user.EmailChangePath, map[string][]string{ + "password": {"password"}, + "new_email": {"admin@example.org"}, + }, suite.userModule.EmailChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusConflict, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`{"error":"Conflict: new email address is already in use on this instance"}`, string(b)) +} + +func (suite *EmailChangeTestSuite) TestEmailChangePOSTSameEmail() { + response, code := suite.POST(user.EmailChangePath, map[string][]string{ + "password": {"password"}, + "new_email": {"zork@example.org"}, + }, suite.userModule.EmailChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusBadRequest, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`{"error":"Bad Request: new email address cannot be the same as current email address"}`, string(b)) +} + +func (suite *EmailChangeTestSuite) TestEmailChangePOSTBadPassword() { + response, code := suite.POST(user.EmailChangePath, map[string][]string{ + "password": {"notmypassword"}, + "new_email": {"someone@example.org"}, + }, suite.userModule.EmailChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusUnauthorized, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`{"error":"Unauthorized: password was incorrect"}`, string(b)) +} + +func TestEmailChangeTestSuite(t *testing.T) { + suite.Run(t, &EmailChangeTestSuite{}) +} diff --git a/internal/api/client/user/passwordchange_test.go b/internal/api/client/user/passwordchange_test.go index b820696b5..8a741f96c 100644 --- a/internal/api/client/user/passwordchange_test.go +++ b/internal/api/client/user/passwordchange_test.go @@ -19,18 +19,13 @@ package user_test import ( "context" - "fmt" - "io/ioutil" + "io" "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/user" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" "golang.org/x/crypto/bcrypt" ) @@ -39,29 +34,20 @@ type PasswordChangeTestSuite struct { } func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) - ctx.Request.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ + response, code := suite.POST(user.PasswordChangePath, map[string][]string{ "old_password": {"password"}, "new_password": {"peepeepoopoopassword"}, - } - suite.userModule.PasswordChangePOSTHandler(ctx) + }, suite.userModule.PasswordChangePOSTHandler) + defer response.Body.Close() - // check response - suite.EqualValues(http.StatusOK, recorder.Code) + // Check response + suite.EqualValues(http.StatusOK, code) dbUser := >smodel.User{} err := suite.db.GetByID(context.Background(), suite.testUsers["local_account_1"].ID, dbUser) - suite.NoError(err) + if err != nil { + suite.FailNow(err.Error()) + } // new password should pass err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("peepeepoopoopassword")) @@ -73,85 +59,49 @@ func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() { } func (suite *PasswordChangeTestSuite) TestPasswordMissingOldPassword() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) - ctx.Request.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ + response, code := suite.POST(user.PasswordChangePath, map[string][]string{ "new_password": {"peepeepoopoopassword"}, + }, suite.userModule.PasswordChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusBadRequest, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) } - suite.userModule.PasswordChangePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) suite.Equal(`{"error":"Bad Request: password change request missing field old_password"}`, string(b)) } func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) - ctx.Request.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ + response, code := suite.POST(user.PasswordChangePath, map[string][]string{ "old_password": {"notright"}, "new_password": {"peepeepoopoopassword"}, + }, suite.userModule.PasswordChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusUnauthorized, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) } - suite.userModule.PasswordChangePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusUnauthorized, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) suite.Equal(`{"error":"Unauthorized: old password was incorrect"}`, string(b)) } func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) - ctx.Request.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ + response, code := suite.POST(user.PasswordChangePath, map[string][]string{ "old_password": {"password"}, "new_password": {"peepeepoopoo"}, + }, suite.userModule.PasswordChangePOSTHandler) + defer response.Body.Close() + + // Check response + suite.EqualValues(http.StatusBadRequest, code) + b, err := io.ReadAll(response.Body) + if err != nil { + suite.FailNow(err.Error()) } - suite.userModule.PasswordChangePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) suite.Equal(`{"error":"Bad Request: password is only 94% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) } diff --git a/internal/api/client/user/user.go b/internal/api/client/user/user.go index 487b9684c..6ad176a2e 100644 --- a/internal/api/client/user/user.go +++ b/internal/api/client/user/user.go @@ -29,6 +29,8 @@ const ( BasePath = "/v1/user" // PasswordChangePath is the path for POSTing a password change request. PasswordChangePath = BasePath + "/password_change" + // EmailChangePath is the path for POSTing an email address change request. + EmailChangePath = BasePath + "/email_change" ) type Module struct { @@ -42,5 +44,7 @@ func New(processor *processing.Processor) *Module { } func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.UserGETHandler) attachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler) + attachHandler(http.MethodPost, EmailChangePath, m.EmailChangePOSTHandler) } diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go index efff89b13..808daf1a3 100644 --- a/internal/api/client/user/user_test.go +++ b/internal/api/client/user/user_test.go @@ -18,14 +18,19 @@ package user_test import ( + "net/http" + "net/http/httptest" + "net/url" + + "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/user" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" @@ -39,7 +44,6 @@ type UserStandardTestSuite struct { tc *typeutils.Converter mediaManager *media.Manager federator *federation.Federator - emailSender email.Sender processor *processing.Processor storage *storage.Driver state state.State @@ -50,8 +54,6 @@ type UserStandardTestSuite struct { testUsers map[string]*gtsmodel.User testAccounts map[string]*gtsmodel.Account - sentEmails map[string]string - userModule *user.Module } @@ -83,9 +85,7 @@ 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.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, testrig.NewEmailSender("../../../../web/template/", nil), suite.mediaManager) suite.userModule = user.New(suite.processor) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") @@ -96,3 +96,32 @@ func (suite *UserStandardTestSuite) TearDownTest() { testrig.StandardStorageTeardown(suite.storage) testrig.StopWorkers(&suite.state) } + +func (suite *UserStandardTestSuite) POST(path string, formValues map[string][]string, handler gin.HandlerFunc) (*http.Response, int) { + var ( + oauthToken = oauth.DBTokenToToken(suite.testTokens["local_account_1"]) + app = suite.testApplications["application_1"] + user = suite.testUsers["local_account_1"] + account = suite.testAccounts["local_account_1"] + target = "http://localhost:8080" + path + ) + + // Prepare context. + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedApplication, app) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, user) + ctx.Set(oauth.SessionAuthorizedAccount, account) + + // Prepare request. + ctx.Request = httptest.NewRequest(http.MethodPost, target, nil) + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values(formValues) + + // Call the handler. + handler(ctx) + + // Return response. + return recorder.Result(), recorder.Code +} diff --git a/internal/api/client/user/userget.go b/internal/api/client/user/userget.go new file mode 100644 index 000000000..147c1dbd5 --- /dev/null +++ b/internal/api/client/user/userget.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 user + +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" +) + +// UserGETHandler swagger:operation GET /api/v1/user getUser +// +// Get your own user model. +// +// --- +// tags: +// - user +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:user +// +// responses: +// '200': +// description: The requested user. +// schema: +// "$ref": "#/definitions/user" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '500': +// description: internal error +func (m *Module) UserGETHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + user, errWithCode := m.processor.User().Get(c.Request.Context(), authed.User) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, user) +} diff --git a/internal/api/model/user.go b/internal/api/model/user.go index 1a70a90d7..9226406d6 100644 --- a/internal/api/model/user.go +++ b/internal/api/model/user.go @@ -17,6 +17,51 @@ package model +// User models fields relevant to one user. +// +// swagger:model user +type User struct { + // Database ID of this user. + // example: 01FBVD42CQ3ZEEVMW180SBX03B + ID string `json:"id"` + // Time this user was created. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // Confirmed email address of this user, if set. + // example: someone@example.org + Email string `json:"email,omitempty"` + // Unconfirmed email address of this user, if set. + // example: someone.else@somewhere.else.example.org + UnconfirmedEmail string `json:"unconfirmed_email,omitempty"` + // Reason for sign-up, if provided. + // example: Please! Pretty please! + Reason string `json:"reason,omitempty"` + // Time at which this user was last emailed, if at all. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + LastEmailedAt string `json:"last_emailed_at,omitempty"` + // Time at which the email given in the `email` field was confirmed, if at all. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + ConfirmedAt string `json:"confirmed_at,omitempty"` + // Time when the last "please confirm your email address" email was sent, if at all. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + ConfirmationSentAt string `json:"confirmation_sent_at,omitempty"` + // User is a moderator. + // example: false + Moderator bool `json:"moderator"` + // User is an admin. + // example: false + Admin bool `json:"admin"` + // User's account is disabled. + // example: false + Disabled bool `json:"disabled"` + // User was approved by an admin. + // example: true + Approved bool `json:"approved"` + // Time when the last "please reset your password" email was sent, if at all. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + ResetPasswordSentAt string `json:"reset_password_sent_at,omitempty"` +} + // PasswordChangeRequest models user password change parameters. // // swagger:parameters userPasswordChange @@ -34,3 +79,19 @@ type PasswordChangeRequest struct { // required: true NewPassword string `form:"new_password" json:"new_password" xml:"new_password" validation:"required"` } + +// EmailChangeRequest models user email change parameters. +// +// swagger:parameters userEmailChange +type EmailChangeRequest struct { + // User's current password, for verification. + // + // in: formData + // required: true + Password string `form:"password" json:"password" xml:"password" validation:"required"` + // Desired new email address. + // + // in: formData + // required: true + NewEmail string `form:"new_email" json:"new_email" xml:"new_email" validation:"required"` +} diff --git a/internal/email/confirm.go b/internal/email/confirm.go index 9f05a4f71..4fbe2a98f 100644 --- a/internal/email/confirm.go +++ b/internal/email/confirm.go @@ -26,13 +26,20 @@ const ( type ConfirmData struct { // Username to be addressed. Username string - // URL of the instance to present to the receiver. + // URL of the instance to + // present to the receiver. InstanceURL string - // Name of the instance to present to the receiver. + // Name of the instance to + // present to the receiver. InstanceName string - // Link to present to the receiver to click on and do the confirmation. - // Should be a full link with protocol eg., https://example.org/confirm_email?token=some-long-token + // Link to present to the receiver to + // click on and do the confirmation. + // Should be a full link with protocol + // eg., https://example.org/confirm_email?token=some-long-token ConfirmLink string + // Is this confirm email being sent + // because this is a new sign-up? + NewSignup bool } func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error { diff --git a/internal/email/email_test.go b/internal/email/email_test.go index b57562cb5..aacca1b3d 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -40,12 +40,13 @@ func (suite *EmailTestSuite) SetupTest() { suite.sender = testrig.NewEmailSender("../../web/template/", suite.sentEmails) } -func (suite *EmailTestSuite) TestTemplateConfirm() { +func (suite *EmailTestSuite) TestTemplateConfirmNewSignup() { confirmData := email.ConfirmData{ Username: "test", InstanceURL: "https://example.org", InstanceName: "Test Instance", ConfirmLink: "https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa", + NewSignup: true, } suite.sender.SendConfirmEmail("user@example.org", confirmData) @@ -53,6 +54,20 @@ func (suite *EmailTestSuite) TestTemplateConfirm() { suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) } +func (suite *EmailTestSuite) TestTemplateConfirm() { + confirmData := email.ConfirmData{ + Username: "test", + InstanceURL: "https://example.org", + InstanceName: "Test Instance", + ConfirmLink: "https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa", + NewSignup: false, + } + + suite.sender.SendConfirmEmail("user@example.org", confirmData) + suite.Len(suite.sentEmails, 1) + suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an email address change on https://example.org.\r\n\r\nTo complete the change, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) +} + func (suite *EmailTestSuite) TestTemplateReset() { resetData := email.ResetData{ Username: "test", diff --git a/internal/federation/federatingdb/delete.go b/internal/federation/federatingdb/delete.go index 7e9b66c5a..931320940 100644 --- a/internal/federation/federatingdb/delete.go +++ b/internal/federation/federatingdb/delete.go @@ -113,7 +113,7 @@ func (f *federatingDB) deleteAccount( log.Debugf(ctx, "deleting account: %s", account.URI) f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityDelete, GTSModel: account, Receiving: receiving, diff --git a/internal/federation/federatingdb/move.go b/internal/federation/federatingdb/move.go index 59dc2529c..681a9cff2 100644 --- a/internal/federation/federatingdb/move.go +++ b/internal/federation/federatingdb/move.go @@ -171,7 +171,7 @@ func (f *federatingDB) Move(ctx context.Context, move vocab.ActivityStreamsMove) // We had a Move already or stored a new Move. // Pass back to a worker for async processing. f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityMove, GTSModel: stubMove, Requesting: requestingAcct, diff --git a/internal/federation/federatingdb/move_test.go b/internal/federation/federatingdb/move_test.go index 3e35dc97a..e9689b1a7 100644 --- a/internal/federation/federatingdb/move_test.go +++ b/internal/federation/federatingdb/move_test.go @@ -78,7 +78,7 @@ func (suite *MoveTestSuite) TestMove() { // Should be a message heading to the processor. msg, _ := suite.getFederatorMsg(5 * time.Second) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) suite.Equal(ap.ActivityMove, msg.APActivityType) // Stub Move should be on the message. @@ -95,7 +95,7 @@ func (suite *MoveTestSuite) TestMove() { // Should be a message heading to the processor // since this is just a straight up retry. msg, _ = suite.getFederatorMsg(5 * time.Second) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) suite.Equal(ap.ActivityMove, msg.APActivityType) // Same as the first Move, but with a different ID. @@ -115,7 +115,7 @@ func (suite *MoveTestSuite) TestMove() { // Should be a message heading to the processor // since this is just a retry with a different ID. msg, _ = suite.getFederatorMsg(5 * time.Second) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) suite.Equal(ap.ActivityMove, msg.APActivityType) } diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go index 2f00e0867..16ecf3443 100644 --- a/internal/federation/federatingdb/update.go +++ b/internal/federation/federatingdb/update.go @@ -99,7 +99,7 @@ func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gts // updating of eg., avatar/header, emojis, etc. The actual db // inserts/updates will take place there. f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityUpdate, GTSModel: requestingAcct, APObject: accountable, diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index dbcecdb0a..65bb40292 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -22,7 +22,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/text" @@ -39,7 +38,6 @@ type Processor struct { state *state.State converter *typeutils.Converter mediaManager *media.Manager - oauthServer oauth.Server filter *visibility.Filter formatter *text.Formatter federator *federation.Federator @@ -53,7 +51,6 @@ func New( state *state.State, converter *typeutils.Converter, mediaManager *media.Manager, - oauthServer oauth.Server, federator *federation.Federator, filter *visibility.Filter, parseMention gtsmodel.ParseMentionFunc, @@ -63,7 +60,6 @@ func New( state: state, converter: converter, mediaManager: mediaManager, - oauthServer: oauthServer, filter: filter, formatter: text.NewFormatter(state.DB), federator: federator, diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 10d5f91e1..556f4d91f 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -29,7 +29,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/common" @@ -48,7 +47,6 @@ type AccountStandardTestSuite struct { storage *storage.Driver state state.State mediaManager *media.Manager - oauthServer oauth.Server transportController transport.Controller federator *federation.Federator emailSender email.Sender @@ -106,7 +104,6 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.storage = testrig.NewInMemoryStorage() suite.state.Storage = suite.storage suite.mediaManager = testrig.NewTestMediaManager(&suite.state) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) @@ -115,7 +112,7 @@ func (suite *AccountStandardTestSuite) SetupTest() { filter := visibility.NewFilter(&suite.state) common := common.New(&suite.state, suite.tc, suite.federator, filter) - suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.oauthServer, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) + suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") } diff --git a/internal/processing/account/create.go b/internal/processing/account/create.go deleted file mode 100644 index 761165356..000000000 --- a/internal/processing/account/create.go +++ /dev/null @@ -1,165 +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 account - -import ( - "context" - "fmt" - "time" - - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/oauth2/v4" -) - -// Create processes the given form for creating a new account, -// returning a new user (with attached account) if successful. -// -// App should be the app used to create the account. -// If nil, the instance app will be used. -// -// Precondition: the form's fields should have already been -// validated and normalized by the caller. -func (p *Processor) Create( - ctx context.Context, - app *gtsmodel.Application, - form *apimodel.AccountCreateRequest, -) (*gtsmodel.User, gtserror.WithCode) { - const ( - usersPerDay = 10 - regBacklog = 20 - ) - - // Ensure no more than usersPerDay - // have registered in the last 24h. - newUsersCount, err := p.state.DB.CountApprovedSignupsSince(ctx, time.Now().Add(-24*time.Hour)) - if err != nil { - err := fmt.Errorf("db error counting new users: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - if newUsersCount >= usersPerDay { - err := fmt.Errorf("this instance has hit its limit of new sign-ups for today; you can try again tomorrow") - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - // Ensure the new users backlog isn't full. - backlogLen, err := p.state.DB.CountUnhandledSignups(ctx) - if err != nil { - err := fmt.Errorf("db error counting registration backlog length: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - if backlogLen >= regBacklog { - err := fmt.Errorf("this instance's sign-up backlog is currently full; you must wait until pending sign-ups are handled by the admin(s)") - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, form.Email) - if err != nil { - err := fmt.Errorf("db error checking email availability: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - if !emailAvailable { - err := fmt.Errorf("email address %s is not available", form.Email) - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - - usernameAvailable, err := p.state.DB.IsUsernameAvailable(ctx, form.Username) - if err != nil { - err := fmt.Errorf("db error checking username availability: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - if !usernameAvailable { - err := fmt.Errorf("username %s is not available", form.Username) - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - - // Only store reason if one is required. - var reason string - if config.GetAccountsReasonRequired() { - reason = form.Reason - } - - // Use instance app if no app provided. - if app == nil { - app, err = p.state.DB.GetInstanceApplication(ctx) - if err != nil { - err := fmt.Errorf("db error getting instance app: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - } - - user, err := p.state.DB.NewSignup(ctx, gtsmodel.NewSignup{ - Username: form.Username, - Email: form.Email, - Password: form.Password, - Reason: text.SanitizeToPlaintext(reason), - SignUpIP: form.IP, - Locale: form.Locale, - AppID: app.ID, - }) - if err != nil { - err := fmt.Errorf("db error creating new signup: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // There are side effects for creating a new account - // (confirmation emails etc), perform these async. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ObjectProfile, - APActivityType: ap.ActivityCreate, - GTSModel: user, - Origin: user.Account, - }) - - return user, nil -} - -// TokenForNewUser generates an OAuth Bearer token -// for a new user (with account) created by Create(). -func (p *Processor) TokenForNewUser( - ctx context.Context, - appToken oauth2.TokenInfo, - app *gtsmodel.Application, - user *gtsmodel.User, -) (*apimodel.Token, gtserror.WithCode) { - // Generate access token. - accessToken, err := p.oauthServer.GenerateUserAccessToken( - ctx, - appToken, - app.ClientSecret, - user.ID, - ) - if err != nil { - err := fmt.Errorf("error creating new access token for user %s: %w", user.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return &apimodel.Token{ - AccessToken: accessToken.GetAccess(), - TokenType: "Bearer", - Scope: accessToken.GetScope(), - CreatedAt: accessToken.GetAccessCreateAt().Unix(), - }, nil -} diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 3f051edf0..075e94544 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -95,23 +95,6 @@ func (p *Processor) Delete( return nil } -// DeleteSelf is like Delete, but specifically for local accounts deleting themselves. -// -// Calling DeleteSelf results in a delete message being enqueued in the processor, -// which causes side effects to occur: delete will be federated out to other instances, -// and the above Delete function will be called afterwards from the processor, to clear -// out the account's bits and bobs, and stubbify it. -func (p *Processor) DeleteSelf(ctx context.Context, account *gtsmodel.Account) gtserror.WithCode { - // Process the delete side effects asynchronously. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ActorPerson, - APActivityType: ap.ActivityDelete, - Origin: account, - Target: account, - }) - return nil -} - // deleteUserAndTokensForAccount deletes the gtsmodel.User and // any OAuth tokens and applications for the given account. // diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 7f2749503..ea6abed6e 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -297,7 +297,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityUpdate, GTSModel: account, Origin: account, diff --git a/internal/processing/account/update_test.go b/internal/processing/account/update_test.go index ad09ff25c..a07562544 100644 --- a/internal/processing/account/update_test.go +++ b/internal/processing/account/update_test.go @@ -64,7 +64,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateSimple() { // Profile update. suite.Equal(ap.ActivityUpdate, msg.APActivityType) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) // Correct account updated. if msg.Origin == nil { @@ -114,7 +114,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithMention() { // Profile update. suite.Equal(ap.ActivityUpdate, msg.APActivityType) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) // Correct account updated. if msg.Origin == nil { @@ -170,7 +170,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithMarkdownNote() { // Profile update. suite.Equal(ap.ActivityUpdate, msg.APActivityType) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) // Correct account updated. if msg.Origin == nil { @@ -255,7 +255,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithFields() { // Profile update. suite.Equal(ap.ActivityUpdate, msg.APActivityType) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) // Correct account updated. if msg.Origin == nil { @@ -312,7 +312,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateNoteNotFields() { // Profile update. suite.Equal(ap.ActivityUpdate, msg.APActivityType) - suite.Equal(ap.ObjectProfile, msg.APObjectType) + suite.Equal(ap.ActorPerson, msg.APObjectType) // Correct account updated. if msg.Origin == nil { diff --git a/internal/processing/account_test.go b/internal/processing/account_test.go deleted file mode 100644 index 82c28115e..000000000 --- a/internal/processing/account_test.go +++ /dev/null @@ -1,106 +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 processing_test - -import ( - "context" - "encoding/json" - "fmt" - "io" - "testing" - "time" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/activity/pub" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AccountTestSuite struct { - ProcessingStandardTestSuite -} - -func (suite *AccountTestSuite) TestAccountDeleteLocal() { - ctx := context.Background() - deletingAccount := suite.testAccounts["local_account_1"] - followingAccount := suite.testAccounts["remote_account_1"] - - // make the following account follow the deleting account so that a delete message will be sent to it via the federating API - follow := >smodel.Follow{ - ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", followingAccount.URI), - AccountID: followingAccount.ID, - TargetAccountID: deletingAccount.ID, - } - err := suite.db.Put(ctx, follow) - suite.NoError(err) - - errWithCode := suite.processor.Account().DeleteSelf(ctx, suite.testAccounts["local_account_1"]) - suite.NoError(errWithCode) - - // the delete should be federated outwards to the following account's inbox - var sent []byte - delete := new(struct { - Actor string `json:"actor"` - ID string `json:"id"` - Object string `json:"object"` - To string `json:"to"` - CC string `json:"cc"` - Type string `json:"type"` - }) - - if !testrig.WaitFor(func() bool { - delivery, ok := suite.state.Workers.Delivery.Queue.Pop() - if !ok { - return false - } - if !testrig.EqualRequestURIs(delivery.Request.URL, *followingAccount.SharedInboxURI) { - panic("differing request uris") - } - sent, err = io.ReadAll(delivery.Request.Body) - if err != nil { - panic("error reading body: " + err.Error()) - } - err = json.Unmarshal(sent, delete) - if err != nil { - panic("error unmarshaling json: " + err.Error()) - } - return true - }) { - suite.FailNow("timed out waiting for message") - } - - suite.Equal(deletingAccount.URI, delete.Actor) - suite.Equal(deletingAccount.URI, delete.Object) - suite.Equal(deletingAccount.FollowersURI, delete.To) - suite.Equal(pub.PublicActivityPubIRI, delete.CC) - suite.Equal("Delete", delete.Type) - - if !testrig.WaitFor(func() bool { - dbAccount, _ := suite.db.GetAccountByID(ctx, deletingAccount.ID) - return !dbAccount.SuspendedAt.IsZero() - }) { - suite.FailNow("timed out waiting for account to be deleted") - } -} - -func TestAccountTestSuite(t *testing.T) { - suite.Run(t, &AccountTestSuite{}) -} diff --git a/internal/processing/admin/accountapprove.go b/internal/processing/admin/accountapprove.go deleted file mode 100644 index c3f6409c3..000000000 --- a/internal/processing/admin/accountapprove.go +++ /dev/null @@ -1,79 +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 admin - -import ( - "context" - "errors" - "fmt" - - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" -) - -func (p *Processor) AccountApprove( - ctx context.Context, - adminAcct *gtsmodel.Account, - accountID string, -) (*apimodel.AdminAccountInfo, gtserror.WithCode) { - user, err := p.state.DB.GetUserByAccountID(ctx, accountID) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - if user == nil { - err := fmt.Errorf("user for account %s not found", accountID) - return nil, gtserror.NewErrorNotFound(err, err.Error()) - } - - // Get a lock on the account URI, - // to ensure it's not also being - // rejected at the same time! - unlock := p.state.ProcessingLocks.Lock(user.Account.URI) - defer unlock() - - if !*user.Approved { - // Process approval side effects asynschronously. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ActorPerson, - APActivityType: ap.ActivityAccept, - GTSModel: user, - Origin: adminAcct, - Target: user.Account, - }) - } - - apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account) - if err != nil { - err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Optimistically set approved to true and - // clear sign-up IP to reflect state that - // will be produced by side effects. - apiAccount.Approved = true - apiAccount.IP = nil - - return apiAccount, nil -} diff --git a/internal/processing/admin/accountapprove_test.go b/internal/processing/admin/accountapprove_test.go deleted file mode 100644 index b6ca1ed32..000000000 --- a/internal/processing/admin/accountapprove_test.go +++ /dev/null @@ -1,75 +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 admin_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AdminApproveTestSuite struct { - AdminStandardTestSuite -} - -func (suite *AdminApproveTestSuite) TestApprove() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - targetAcct = suite.testAccounts["unconfirmed_account"] - targetUser = new(gtsmodel.User) - ) - - // Copy user since we're modifying it. - *targetUser = *suite.testUsers["unconfirmed_account"] - - // Approve the sign-up. - acct, errWithCode := suite.adminProcessor.AccountApprove( - ctx, - adminAcct, - targetAcct.ID, - ) - if errWithCode != nil { - suite.FailNow(errWithCode.Error()) - } - - // Account should be approved. - suite.NotNil(acct) - suite.True(acct.Approved) - suite.Nil(acct.IP) - - // Wait for processor to - // handle side effects. - var ( - dbUser *gtsmodel.User - err error - ) - if !testrig.WaitFor(func() bool { - dbUser, err = suite.state.DB.GetUserByID(ctx, targetUser.ID) - return err == nil && dbUser != nil && *dbUser.Approved - }) { - suite.FailNow("waiting for approved user") - } -} - -func TestAdminApproveTestSuite(t *testing.T) { - suite.Run(t, new(AdminApproveTestSuite)) -} diff --git a/internal/processing/admin/accountreject.go b/internal/processing/admin/accountreject.go deleted file mode 100644 index 8cb54cad6..000000000 --- a/internal/processing/admin/accountreject.go +++ /dev/null @@ -1,113 +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 admin - -import ( - "context" - "errors" - "fmt" - - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" -) - -func (p *Processor) AccountReject( - ctx context.Context, - adminAcct *gtsmodel.Account, - accountID string, - privateComment string, - sendEmail bool, - message string, -) (*apimodel.AdminAccountInfo, gtserror.WithCode) { - user, err := p.state.DB.GetUserByAccountID(ctx, accountID) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - if user == nil { - err := fmt.Errorf("user for account %s not found", accountID) - return nil, gtserror.NewErrorNotFound(err, err.Error()) - } - - // Get a lock on the account URI, - // since we're going to be deleting - // it and its associated user. - unlock := p.state.ProcessingLocks.Lock(user.Account.URI) - defer unlock() - - // Can't reject an account with a - // user that's already been approved. - if *user.Approved { - err := fmt.Errorf("account %s has already been approved", accountID) - return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) - } - - // Convert to API account *before* doing the - // rejection, since the rejection will cause - // the user and account to be removed. - apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account) - if err != nil { - err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Set approved to false on the API model, to - // reflect the changes that will occur - // asynchronously in the processor. - apiAccount.Approved = false - - // Ensure we an email address. - var email string - if user.Email != "" { - email = user.Email - } else { - email = user.UnconfirmedEmail - } - - // Create a denied user entry for - // the worker to process + store. - deniedUser := >smodel.DeniedUser{ - ID: user.ID, - Email: email, - Username: user.Account.Username, - SignUpIP: user.SignUpIP, - InviteID: user.InviteID, - Locale: user.Locale, - CreatedByApplicationID: user.CreatedByApplicationID, - SignUpReason: user.Reason, - PrivateComment: privateComment, - SendEmail: &sendEmail, - Message: message, - } - - // Process rejection side effects asynschronously. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ActorPerson, - APActivityType: ap.ActivityReject, - GTSModel: deniedUser, - Origin: adminAcct, - Target: user.Account, - }) - - return apiAccount, nil -} diff --git a/internal/processing/admin/accountreject_test.go b/internal/processing/admin/accountreject_test.go deleted file mode 100644 index 071401afc..000000000 --- a/internal/processing/admin/accountreject_test.go +++ /dev/null @@ -1,142 +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 admin_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AdminRejectTestSuite struct { - AdminStandardTestSuite -} - -func (suite *AdminRejectTestSuite) TestReject() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - targetAcct = suite.testAccounts["unconfirmed_account"] - targetUser = suite.testUsers["unconfirmed_account"] - privateComment = "It's a no from me chief." - sendEmail = true - message = "Too stinky." - ) - - acct, errWithCode := suite.adminProcessor.AccountReject( - ctx, - adminAcct, - targetAcct.ID, - privateComment, - sendEmail, - message, - ) - if errWithCode != nil { - suite.FailNow(errWithCode.Error()) - } - suite.NotNil(acct) - suite.False(acct.Approved) - - // Wait for processor to - // handle side effects. - var ( - deniedUser *gtsmodel.DeniedUser - err error - ) - if !testrig.WaitFor(func() bool { - deniedUser, err = suite.state.DB.GetDeniedUserByID(ctx, targetUser.ID) - return deniedUser != nil && err == nil - }) { - suite.FailNow("waiting for denied user") - } - - // Ensure fields as expected. - suite.Equal(targetUser.ID, deniedUser.ID) - suite.Equal(targetUser.UnconfirmedEmail, deniedUser.Email) - suite.Equal(targetAcct.Username, deniedUser.Username) - suite.Equal(targetUser.SignUpIP, deniedUser.SignUpIP) - suite.Equal(targetUser.InviteID, deniedUser.InviteID) - suite.Equal(targetUser.Locale, deniedUser.Locale) - suite.Equal(targetUser.CreatedByApplicationID, deniedUser.CreatedByApplicationID) - suite.Equal(targetUser.Reason, deniedUser.SignUpReason) - suite.Equal(privateComment, deniedUser.PrivateComment) - suite.Equal(sendEmail, *deniedUser.SendEmail) - suite.Equal(message, deniedUser.Message) - - // Should be no user entry for - // this denied request now. - _, err = suite.state.DB.GetUserByID(ctx, targetUser.ID) - suite.ErrorIs(db.ErrNoEntries, err) - - // Should be no account entry for - // this denied request now. - _, err = suite.state.DB.GetAccountByID(ctx, targetAcct.ID) - suite.ErrorIs(db.ErrNoEntries, err) -} - -func (suite *AdminRejectTestSuite) TestRejectRemote() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - targetAcct = suite.testAccounts["remote_account_1"] - privateComment = "It's a no from me chief." - sendEmail = true - message = "Too stinky." - ) - - // Try to reject a remote account. - _, err := suite.adminProcessor.AccountReject( - ctx, - adminAcct, - targetAcct.ID, - privateComment, - sendEmail, - message, - ) - suite.EqualError(err, "user for account 01F8MH5ZK5VRH73AKHQM6Y9VNX not found") -} - -func (suite *AdminRejectTestSuite) TestRejectApproved() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - targetAcct = suite.testAccounts["local_account_1"] - privateComment = "It's a no from me chief." - sendEmail = true - message = "Too stinky." - ) - - // Try to reject an already-approved account. - _, err := suite.adminProcessor.AccountReject( - ctx, - adminAcct, - targetAcct.ID, - privateComment, - sendEmail, - message, - ) - suite.EqualError(err, "account 01F8MH1H7YV1Z7D2C8K2730QBF has already been approved") -} - -func TestAdminRejectTestSuite(t *testing.T) { - suite.Run(t, new(AdminRejectTestSuite)) -} diff --git a/internal/processing/admin/signupapprove.go b/internal/processing/admin/signupapprove.go new file mode 100644 index 000000000..84e04fa8d --- /dev/null +++ b/internal/processing/admin/signupapprove.go @@ -0,0 +1,82 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" +) + +func (p *Processor) SignupApprove( + ctx context.Context, + adminAcct *gtsmodel.Account, + accountID string, +) (*apimodel.AdminAccountInfo, gtserror.WithCode) { + user, err := p.state.DB.GetUserByAccountID(ctx, accountID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if user == nil { + err := fmt.Errorf("user for account %s not found", accountID) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Get a lock on the account URI, + // to ensure it's not also being + // rejected at the same time! + unlock := p.state.ProcessingLocks.Lock(user.Account.URI) + defer unlock() + + if !*user.Approved { + // Process approval side effects asynschronously. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + // Use ap.ObjectProfile here to + // distinguish this message (user model) + // from ap.ActorPerson (account model). + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityAccept, + GTSModel: user, + Origin: adminAcct, + Target: user.Account, + }) + } + + apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account) + if err != nil { + err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Optimistically set approved to true and + // clear sign-up IP to reflect state that + // will be produced by side effects. + apiAccount.Approved = true + apiAccount.IP = nil + + return apiAccount, nil +} diff --git a/internal/processing/admin/signupapprove_test.go b/internal/processing/admin/signupapprove_test.go new file mode 100644 index 000000000..58b8fdade --- /dev/null +++ b/internal/processing/admin/signupapprove_test.go @@ -0,0 +1,75 @@ +// 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 admin_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AdminApproveTestSuite struct { + AdminStandardTestSuite +} + +func (suite *AdminApproveTestSuite) TestApprove() { + var ( + ctx = context.Background() + adminAcct = suite.testAccounts["admin_account"] + targetAcct = suite.testAccounts["unconfirmed_account"] + targetUser = new(gtsmodel.User) + ) + + // Copy user since we're modifying it. + *targetUser = *suite.testUsers["unconfirmed_account"] + + // Approve the sign-up. + acct, errWithCode := suite.adminProcessor.SignupApprove( + ctx, + adminAcct, + targetAcct.ID, + ) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + + // Account should be approved. + suite.NotNil(acct) + suite.True(acct.Approved) + suite.Nil(acct.IP) + + // Wait for processor to + // handle side effects. + var ( + dbUser *gtsmodel.User + err error + ) + if !testrig.WaitFor(func() bool { + dbUser, err = suite.state.DB.GetUserByID(ctx, targetUser.ID) + return err == nil && dbUser != nil && *dbUser.Approved + }) { + suite.FailNow("waiting for approved user") + } +} + +func TestAdminApproveTestSuite(t *testing.T) { + suite.Run(t, new(AdminApproveTestSuite)) +} diff --git a/internal/processing/admin/signupreject.go b/internal/processing/admin/signupreject.go new file mode 100644 index 000000000..39eff0b87 --- /dev/null +++ b/internal/processing/admin/signupreject.go @@ -0,0 +1,116 @@ +// 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 admin + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" +) + +func (p *Processor) SignupReject( + ctx context.Context, + adminAcct *gtsmodel.Account, + accountID string, + privateComment string, + sendEmail bool, + message string, +) (*apimodel.AdminAccountInfo, gtserror.WithCode) { + user, err := p.state.DB.GetUserByAccountID(ctx, accountID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if user == nil { + err := fmt.Errorf("user for account %s not found", accountID) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Get a lock on the account URI, + // since we're going to be deleting + // it and its associated user. + unlock := p.state.ProcessingLocks.Lock(user.Account.URI) + defer unlock() + + // Can't reject an account with a + // user that's already been approved. + if *user.Approved { + err := fmt.Errorf("account %s has already been approved", accountID) + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Convert to API account *before* doing the + // rejection, since the rejection will cause + // the user and account to be removed. + apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account) + if err != nil { + err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Set approved to false on the API model, to + // reflect the changes that will occur + // asynchronously in the processor. + apiAccount.Approved = false + + // Ensure we an email address. + var email string + if user.Email != "" { + email = user.Email + } else { + email = user.UnconfirmedEmail + } + + // Create a denied user entry for + // the worker to process + store. + deniedUser := >smodel.DeniedUser{ + ID: user.ID, + Email: email, + Username: user.Account.Username, + SignUpIP: user.SignUpIP, + InviteID: user.InviteID, + Locale: user.Locale, + CreatedByApplicationID: user.CreatedByApplicationID, + SignUpReason: user.Reason, + PrivateComment: privateComment, + SendEmail: &sendEmail, + Message: message, + } + + // Process rejection side effects asynschronously. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + // Use ap.ObjectProfile here to + // distinguish this message (user model) + // from ap.ActorPerson (account model). + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityReject, + GTSModel: deniedUser, + Origin: adminAcct, + Target: user.Account, + }) + + return apiAccount, nil +} diff --git a/internal/processing/admin/signupreject_test.go b/internal/processing/admin/signupreject_test.go new file mode 100644 index 000000000..cb6a25eb3 --- /dev/null +++ b/internal/processing/admin/signupreject_test.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 admin_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AdminRejectTestSuite struct { + AdminStandardTestSuite +} + +func (suite *AdminRejectTestSuite) TestReject() { + var ( + ctx = context.Background() + adminAcct = suite.testAccounts["admin_account"] + targetAcct = suite.testAccounts["unconfirmed_account"] + targetUser = suite.testUsers["unconfirmed_account"] + privateComment = "It's a no from me chief." + sendEmail = true + message = "Too stinky." + ) + + acct, errWithCode := suite.adminProcessor.SignupReject( + ctx, + adminAcct, + targetAcct.ID, + privateComment, + sendEmail, + message, + ) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + suite.NotNil(acct) + suite.False(acct.Approved) + + // Wait for processor to + // handle side effects. + var ( + deniedUser *gtsmodel.DeniedUser + err error + ) + if !testrig.WaitFor(func() bool { + deniedUser, err = suite.state.DB.GetDeniedUserByID(ctx, targetUser.ID) + return deniedUser != nil && err == nil + }) { + suite.FailNow("waiting for denied user") + } + + // Ensure fields as expected. + suite.Equal(targetUser.ID, deniedUser.ID) + suite.Equal(targetUser.UnconfirmedEmail, deniedUser.Email) + suite.Equal(targetAcct.Username, deniedUser.Username) + suite.Equal(targetUser.SignUpIP, deniedUser.SignUpIP) + suite.Equal(targetUser.InviteID, deniedUser.InviteID) + suite.Equal(targetUser.Locale, deniedUser.Locale) + suite.Equal(targetUser.CreatedByApplicationID, deniedUser.CreatedByApplicationID) + suite.Equal(targetUser.Reason, deniedUser.SignUpReason) + suite.Equal(privateComment, deniedUser.PrivateComment) + suite.Equal(sendEmail, *deniedUser.SendEmail) + suite.Equal(message, deniedUser.Message) + + // Should be no user entry for + // this denied request now. + _, err = suite.state.DB.GetUserByID(ctx, targetUser.ID) + suite.ErrorIs(db.ErrNoEntries, err) + + // Should be no account entry for + // this denied request now. + _, err = suite.state.DB.GetAccountByID(ctx, targetAcct.ID) + suite.ErrorIs(db.ErrNoEntries, err) +} + +func (suite *AdminRejectTestSuite) TestRejectRemote() { + var ( + ctx = context.Background() + adminAcct = suite.testAccounts["admin_account"] + targetAcct = suite.testAccounts["remote_account_1"] + privateComment = "It's a no from me chief." + sendEmail = true + message = "Too stinky." + ) + + // Try to reject a remote account. + _, err := suite.adminProcessor.SignupReject( + ctx, + adminAcct, + targetAcct.ID, + privateComment, + sendEmail, + message, + ) + suite.EqualError(err, "user for account 01F8MH5ZK5VRH73AKHQM6Y9VNX not found") +} + +func (suite *AdminRejectTestSuite) TestRejectApproved() { + var ( + ctx = context.Background() + adminAcct = suite.testAccounts["admin_account"] + targetAcct = suite.testAccounts["local_account_1"] + privateComment = "It's a no from me chief." + sendEmail = true + message = "Too stinky." + ) + + // Try to reject an already-approved account. + _, err := suite.adminProcessor.SignupReject( + ctx, + adminAcct, + targetAcct.ID, + privateComment, + sendEmail, + message, + ) + suite.EqualError(err, "account 01F8MH1H7YV1Z7D2C8K2730QBF has already been approved") +} + +func TestAdminRejectTestSuite(t *testing.T) { + suite.Run(t, new(AdminRejectTestSuite)) +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8a18bc45e..1e7997b8f 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -180,13 +180,13 @@ func NewProcessor( // Start with sub processors that will // be required by the workers processor. common := common.New(state, converter, federator, filter) - processor.account = account.New(&common, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) + processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) processor.media = media.New(state, converter, mediaManager, federator.TransportController()) processor.stream = stream.New(state, oauthServer) // Instantiate the rest of the sub // processors + pin them to this struct. - processor.account = account.New(&common, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) + processor.account = account.New(&common, state, converter, mediaManager, federator, filter, parseMentionFunc) processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) processor.fedi = fedi.New(state, &common, converter, federator, filter) processor.filtersv1 = filtersv1.New(state, converter) @@ -198,7 +198,7 @@ func NewProcessor( processor.timeline = timeline.New(state, converter, filter) processor.search = search.New(state, federator, converter, filter) processor.status = status.New(state, &common, &processor.polls, federator, converter, filter, parseMentionFunc) - processor.user = user.New(state, emailSender) + processor.user = user.New(state, converter, oauthServer, emailSender) // Workers processor handles asynchronous // worker jobs; instantiate it separately diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go index cac600006..dd31a8798 100644 --- a/internal/processing/report/create.go +++ b/internal/processing/report/create.go @@ -92,7 +92,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form } p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityFlag, GTSModel: report, Origin: account, diff --git a/internal/processing/user/create.go b/internal/processing/user/create.go new file mode 100644 index 000000000..0d848583e --- /dev/null +++ b/internal/processing/user/create.go @@ -0,0 +1,167 @@ +// 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 user + +import ( + "context" + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/text" + "github.com/superseriousbusiness/oauth2/v4" +) + +// Create processes the given form for creating a new user+account. +// +// App should be the app used to create the user+account. +// If nil, the instance app will be used. +// +// Precondition: the form's fields should have already been +// validated and normalized by the caller. +func (p *Processor) Create( + ctx context.Context, + app *gtsmodel.Application, + form *apimodel.AccountCreateRequest, +) (*gtsmodel.User, gtserror.WithCode) { + const ( + usersPerDay = 10 + regBacklog = 20 + ) + + // Ensure no more than usersPerDay + // have registered in the last 24h. + newUsersCount, err := p.state.DB.CountApprovedSignupsSince(ctx, time.Now().Add(-24*time.Hour)) + if err != nil { + err := fmt.Errorf("db error counting new users: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if newUsersCount >= usersPerDay { + err := fmt.Errorf("this instance has hit its limit of new sign-ups for today; you can try again tomorrow") + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Ensure the new users backlog isn't full. + backlogLen, err := p.state.DB.CountUnhandledSignups(ctx) + if err != nil { + err := fmt.Errorf("db error counting registration backlog length: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if backlogLen >= regBacklog { + err := fmt.Errorf("this instance's sign-up backlog is currently full; you must wait until pending sign-ups are handled by the admin(s)") + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, form.Email) + if err != nil { + err := fmt.Errorf("db error checking email availability: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + if !emailAvailable { + err := fmt.Errorf("email address %s is not available", form.Email) + return nil, gtserror.NewErrorConflict(err, err.Error()) + } + + usernameAvailable, err := p.state.DB.IsUsernameAvailable(ctx, form.Username) + if err != nil { + err := fmt.Errorf("db error checking username availability: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + if !usernameAvailable { + err := fmt.Errorf("username %s is not available", form.Username) + return nil, gtserror.NewErrorConflict(err, err.Error()) + } + + // Only store reason if one is required. + var reason string + if config.GetAccountsReasonRequired() { + reason = form.Reason + } + + // Use instance app if no app provided. + if app == nil { + app, err = p.state.DB.GetInstanceApplication(ctx) + if err != nil { + err := fmt.Errorf("db error getting instance app: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + user, err := p.state.DB.NewSignup(ctx, gtsmodel.NewSignup{ + Username: form.Username, + Email: form.Email, + Password: form.Password, + Reason: text.SanitizeToPlaintext(reason), + SignUpIP: form.IP, + Locale: form.Locale, + AppID: app.ID, + }) + if err != nil { + err := fmt.Errorf("db error creating new signup: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // There are side effects for creating a new user+account + // (confirmation emails etc), perform these async. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + // Use ap.ObjectProfile here to + // distinguish this message (user model) + // from ap.ActorPerson (account model). + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityCreate, + GTSModel: user, + Origin: user.Account, + }) + + return user, nil +} + +// TokenForNewUser generates an OAuth Bearer token +// for a new user (with account) created by Create(). +func (p *Processor) TokenForNewUser( + ctx context.Context, + appToken oauth2.TokenInfo, + app *gtsmodel.Application, + user *gtsmodel.User, +) (*apimodel.Token, gtserror.WithCode) { + // Generate access token. + accessToken, err := p.oauthServer.GenerateUserAccessToken( + ctx, + appToken, + app.ClientSecret, + user.ID, + ) + if err != nil { + err := fmt.Errorf("error creating new access token for user %s: %w", user.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return &apimodel.Token{ + AccessToken: accessToken.GetAccess(), + TokenType: "Bearer", + Scope: accessToken.GetScope(), + CreatedAt: accessToken.GetAccessCreateAt().Unix(), + }, nil +} diff --git a/internal/processing/user/delete.go b/internal/processing/user/delete.go new file mode 100644 index 000000000..9783010ef --- /dev/null +++ b/internal/processing/user/delete.go @@ -0,0 +1,48 @@ +// 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 user + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" +) + +// DeleteSelf is like Account.Delete, but specifically +// for local user+accounts deleting themselves. +// +// Calling DeleteSelf results in a delete message being enqueued in the processor, +// which causes side effects to occur: delete will be federated out to other instances, +// and the above Delete function will be called afterwards from the processor, to clear +// out the account's bits and bobs, and stubbify it. +func (p *Processor) DeleteSelf(ctx context.Context, account *gtsmodel.Account) gtserror.WithCode { + // Process the delete side effects asynchronously. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + // Use ap.ObjectProfile here to + // distinguish this message (user model) + // from ap.ActorPerson (account model). + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityDelete, + Origin: account, + Target: account, + }) + return nil +} diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go index 2b27c6c92..ea9dbb64c 100644 --- a/internal/processing/user/email.go +++ b/internal/processing/user/email.go @@ -23,11 +23,92 @@ import ( "fmt" "time" + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/validate" + "golang.org/x/crypto/bcrypt" ) +// EmailChange processes an email address change request for the given user. +func (p *Processor) EmailChange( + ctx context.Context, + user *gtsmodel.User, + password string, + newEmail string, +) (*apimodel.User, gtserror.WithCode) { + // Ensure provided password is correct. + if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { + err := gtserror.Newf("%w", err) + return nil, gtserror.NewErrorUnauthorized(err, "password was incorrect") + } + + // Ensure new email address is valid. + if err := validate.Email(newEmail); err != nil { + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + // Ensure new email address is different + // from current email address. + if newEmail == user.Email { + const help = "new email address cannot be the same as current email address" + err := gtserror.New(help) + return nil, gtserror.NewErrorBadRequest(err, help) + } + + if newEmail == user.UnconfirmedEmail { + const help = "you already have an email change request pending for given email address" + err := gtserror.New(help) + return nil, gtserror.NewErrorBadRequest(err, help) + } + + // Ensure this address isn't already used by another account. + emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, newEmail) + if err != nil { + err := gtserror.Newf("db error checking email availability: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if !emailAvailable { + const help = "new email address is already in use on this instance" + err := gtserror.New(help) + return nil, gtserror.NewErrorConflict(err, help) + } + + // Set new email address on user. + user.UnconfirmedEmail = newEmail + if err := p.state.DB.UpdateUser( + ctx, user, + "unconfirmed_email", + ); err != nil { + err := gtserror.Newf("db error updating user: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Ensure user populated (we need account). + if err := p.state.DB.PopulateUser(ctx, user); err != nil { + err := gtserror.Newf("db error populating user: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Add email sending job to the queue. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + // Use ap.ObjectProfile here to + // distinguish this message (user model) + // from ap.ActorPerson (account model). + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityUpdate, + GTSModel: user, + Origin: user.Account, + Target: user.Account, + }) + + return p.converter.UserToAPIUser(ctx, user), nil +} + // EmailGetUserForConfirmToken retrieves the user (with account) from // the database for the given "confirm your email" token string. func (p *Processor) EmailGetUserForConfirmToken(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { diff --git a/internal/processing/user/get.go b/internal/processing/user/get.go new file mode 100644 index 000000000..9b19189a8 --- /dev/null +++ b/internal/processing/user/get.go @@ -0,0 +1,32 @@ +// 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 user + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Get returns the API model of the given user. +// Should only be served if user == the user doing the request. +func (p *Processor) Get(ctx context.Context, user *gtsmodel.User) (*apimodel.User, gtserror.WithCode) { + return p.converter.UserToAPIUser(ctx, user), nil +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go index 2fbb9c888..cd8ab9900 100644 --- a/internal/processing/user/user.go +++ b/internal/processing/user/user.go @@ -19,18 +19,28 @@ package user import ( "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type Processor struct { state *state.State + converter *typeutils.Converter + oauthServer oauth.Server emailSender email.Sender } -// New returns a new user processor -func New(state *state.State, emailSender email.Sender) Processor { +// New returns a new user processor. +func New( + state *state.State, + converter *typeutils.Converter, + oauthServer oauth.Server, + emailSender email.Sender, +) Processor { return Processor{ state: state, + converter: converter, emailSender: emailSender, } } diff --git a/internal/processing/user/user_test.go b/internal/processing/user/user_test.go index 61e8f8b05..e473c5bb0 100644 --- a/internal/processing/user/user_test.go +++ b/internal/processing/user/user_test.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing/user" "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -53,7 +54,7 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) suite.testUsers = testrig.NewTestUsers() - suite.user = user.New(&suite.state, suite.emailSender) + suite.user = user.New(&suite.state, typeutils.NewConverter(&suite.state), testrig.NewTestOauthServer(suite.db), suite.emailSender) testrig.StandardDBSetup(suite.db, nil) } diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index a9e33892f..89b8f546f 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -71,9 +71,9 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ActivityCreate: switch cMsg.APObjectType { - // CREATE PROFILE/ACCOUNT - case ap.ObjectProfile, ap.ActorPerson: - return p.clientAPI.CreateAccount(ctx, cMsg) + // CREATE USER (ie., new user+account sign-up) + case ap.ObjectProfile: + return p.clientAPI.CreateUser(ctx, cMsg) // CREATE NOTE/STATUS case ap.ObjectNote: @@ -111,13 +111,17 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ObjectNote: return p.clientAPI.UpdateStatus(ctx, cMsg) - // UPDATE PROFILE/ACCOUNT - case ap.ObjectProfile, ap.ActorPerson: + // UPDATE ACCOUNT (ie., bio, settings, etc) + case ap.ActorPerson: return p.clientAPI.UpdateAccount(ctx, cMsg) // UPDATE A FLAG/REPORT (mark as resolved/closed) case ap.ActivityFlag: return p.clientAPI.UpdateReport(ctx, cMsg) + + // UPDATE USER (ie., email address) + case ap.ObjectProfile: + return p.clientAPI.UpdateUser(ctx, cMsg) } // ACCEPT SOMETHING @@ -128,9 +132,9 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ActivityFollow: return p.clientAPI.AcceptFollow(ctx, cMsg) - // ACCEPT PROFILE/ACCOUNT (sign-up) - case ap.ObjectProfile, ap.ActorPerson: - return p.clientAPI.AcceptAccount(ctx, cMsg) + // ACCEPT USER (ie., new user+account sign-up) + case ap.ObjectProfile: + return p.clientAPI.AcceptUser(ctx, cMsg) } // REJECT SOMETHING @@ -141,9 +145,9 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ActivityFollow: return p.clientAPI.RejectFollowRequest(ctx, cMsg) - // REJECT PROFILE/ACCOUNT (sign-up) - case ap.ObjectProfile, ap.ActorPerson: - return p.clientAPI.RejectAccount(ctx, cMsg) + // REJECT USER (ie., new user+account sign-up) + case ap.ObjectProfile: + return p.clientAPI.RejectUser(ctx, cMsg) } // UNDO SOMETHING @@ -175,17 +179,17 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ObjectNote: return p.clientAPI.DeleteStatus(ctx, cMsg) - // DELETE PROFILE/ACCOUNT - case ap.ObjectProfile, ap.ActorPerson: - return p.clientAPI.DeleteAccount(ctx, cMsg) + // DELETE REMOTE ACCOUNT or LOCAL USER+ACCOUNT + case ap.ActorPerson, ap.ObjectProfile: + return p.clientAPI.DeleteAccountOrUser(ctx, cMsg) } // FLAG/REPORT SOMETHING case ap.ActivityFlag: switch cMsg.APObjectType { //nolint:gocritic - // FLAG/REPORT A PROFILE - case ap.ObjectProfile: + // FLAG/REPORT ACCOUNT + case ap.ActorPerson: return p.clientAPI.ReportAccount(ctx, cMsg) } @@ -193,8 +197,8 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro case ap.ActivityMove: switch cMsg.APObjectType { //nolint:gocritic - // MOVE PROFILE/ACCOUNT - case ap.ObjectProfile, ap.ActorPerson: + // MOVE ACCOUNT + case ap.ActorPerson: return p.clientAPI.MoveAccount(ctx, cMsg) } } @@ -202,7 +206,7 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro return gtserror.Newf("unhandled: %s %s", cMsg.APActivityType, cMsg.APObjectType) } -func (p *clientAPI) CreateAccount(ctx context.Context, cMsg *messages.FromClientAPI) error { +func (p *clientAPI) CreateUser(ctx context.Context, cMsg *messages.FromClientAPI) error { newUser, ok := cMsg.GTSModel.(*gtsmodel.User) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.User", cMsg.GTSModel) @@ -219,7 +223,7 @@ func (p *clientAPI) CreateAccount(ctx context.Context, cMsg *messages.FromClient } // Send "please confirm your address" email to the new user. - if err := p.surface.emailUserPleaseConfirm(ctx, newUser); err != nil { + if err := p.surface.emailUserPleaseConfirm(ctx, newUser, true); err != nil { log.Errorf(ctx, "error emailing confirm: %v", err) } @@ -479,6 +483,22 @@ func (p *clientAPI) UpdateReport(ctx context.Context, cMsg *messages.FromClientA return nil } +func (p *clientAPI) UpdateUser(ctx context.Context, cMsg *messages.FromClientAPI) error { + user, ok := cMsg.GTSModel.(*gtsmodel.User) + if !ok { + return gtserror.Newf("cannot cast %T -> *gtsmodel.User", cMsg.GTSModel) + } + + // The only possible "UpdateUser" action is to update the + // user's email address, so we can safely assume by this + // point that a new unconfirmed email address has been set. + if err := p.surface.emailUserPleaseConfirm(ctx, user, false); err != nil { + log.Errorf(ctx, "error emailing report closed: %v", err) + } + + return nil +} + func (p *clientAPI) AcceptFollow(ctx context.Context, cMsg *messages.FromClientAPI) error { follow, ok := cMsg.GTSModel.(*gtsmodel.Follow) if !ok { @@ -669,7 +689,7 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA return nil } -func (p *clientAPI) DeleteAccount(ctx context.Context, cMsg *messages.FromClientAPI) error { +func (p *clientAPI) DeleteAccountOrUser(ctx context.Context, cMsg *messages.FromClientAPI) error { // The originID of the delete, one of: // - ID of a domain block, for which // this account delete is a side effect. @@ -768,7 +788,7 @@ func (p *clientAPI) MoveAccount(ctx context.Context, cMsg *messages.FromClientAP return nil } -func (p *clientAPI) AcceptAccount(ctx context.Context, cMsg *messages.FromClientAPI) error { +func (p *clientAPI) AcceptUser(ctx context.Context, cMsg *messages.FromClientAPI) error { newUser, ok := cMsg.GTSModel.(*gtsmodel.User) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.User", cMsg.GTSModel) @@ -791,7 +811,7 @@ func (p *clientAPI) AcceptAccount(ctx context.Context, cMsg *messages.FromClient return nil } -func (p *clientAPI) RejectAccount(ctx context.Context, cMsg *messages.FromClientAPI) error { +func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI) error { deniedUser, ok := cMsg.GTSModel.(*gtsmodel.DeniedUser) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.DeniedUser", cMsg.GTSModel) diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 49756a47a..ac4003f6a 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -115,8 +115,8 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF case ap.ObjectNote: return p.fediAPI.UpdateStatus(ctx, fMsg) - // UPDATE PROFILE/ACCOUNT - case ap.ObjectProfile: + // UPDATE ACCOUNT + case ap.ActorPerson: return p.fediAPI.UpdateAccount(ctx, fMsg) } @@ -137,17 +137,17 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF case ap.ObjectNote: return p.fediAPI.DeleteStatus(ctx, fMsg) - // DELETE PROFILE/ACCOUNT - case ap.ObjectProfile: + // DELETE ACCOUNT + case ap.ActorPerson: return p.fediAPI.DeleteAccount(ctx, fMsg) } // MOVE SOMETHING case ap.ActivityMove: - // MOVE PROFILE/ACCOUNT + // MOVE ACCOUNT // fromfediapi_move.go. - if fMsg.APObjectType == ap.ObjectProfile { + if fMsg.APObjectType == ap.ActorPerson { return p.fediAPI.MoveAccount(ctx, fMsg) } } diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go index 8429fe17c..e69e2c7a8 100644 --- a/internal/processing/workers/fromfediapi_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -337,7 +337,7 @@ func (suite *FromFediAPITestSuite) TestProcessAccountDelete() { // now they are mufos! err = testStructs.Processor.Workers().ProcessFromFediAPI(ctx, &messages.FromFediAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityDelete, GTSModel: deletedAccount, Receiving: receivingAccount, @@ -613,7 +613,7 @@ func (suite *FromFediAPITestSuite) TestMoveAccount() { // Process the Move. err := testStructs.Processor.Workers().ProcessFromFediAPI(ctx, &messages.FromFediAPI{ - APObjectType: ap.ObjectProfile, + APObjectType: ap.ActorPerson, APActivityType: ap.ActivityMove, GTSModel: >smodel.Move{ OriginURI: requestingAcct.URI, diff --git a/internal/processing/workers/surfaceemail.go b/internal/processing/workers/surfaceemail.go index 5f8ae1823..d0a40e6ba 100644 --- a/internal/processing/workers/surfaceemail.go +++ b/internal/processing/workers/surfaceemail.go @@ -74,7 +74,10 @@ func (s *Surface) emailUserReportClosed(ctx context.Context, report *gtsmodel.Re // emailUserPleaseConfirm emails the given user // to ask them to confirm their email address. -func (s *Surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.User) error { +// +// If newSignup is true, template will be geared +// towards someone who just created an account. +func (s *Surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.User, newSignup bool) error { if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { // User has already confirmed this @@ -104,6 +107,7 @@ func (s *Surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.Use InstanceURL: instance.URI, InstanceName: instance.Title, ConfirmLink: confirmLink, + NewSignup: newSignup, }, ); err != nil { return err diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 68db61128..e1380fc9e 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -63,6 +63,44 @@ func toMastodonVersion(in string) string { return instanceMastodonVersion + "+" + strings.ReplaceAll(in, " ", "-") } +// UserToAPIUser converts a *gtsmodel.User to an API +// representation suitable for serving to that user. +// +// Contains sensitive info so should only +// ever be served to the user themself. +func (c *Converter) UserToAPIUser(ctx context.Context, u *gtsmodel.User) *apimodel.User { + user := &apimodel.User{ + ID: u.ID, + CreatedAt: util.FormatISO8601(u.CreatedAt), + Email: u.Email, + UnconfirmedEmail: u.UnconfirmedEmail, + Reason: u.Reason, + Moderator: *u.Moderator, + Admin: *u.Admin, + Disabled: *u.Disabled, + Approved: *u.Approved, + } + + // Zero-able dates. + if !u.LastEmailedAt.IsZero() { + user.LastEmailedAt = util.FormatISO8601(u.LastEmailedAt) + } + + if !u.ConfirmedAt.IsZero() { + user.ConfirmedAt = util.FormatISO8601(u.ConfirmedAt) + } + + if !u.ConfirmationSentAt.IsZero() { + user.ConfirmationSentAt = util.FormatISO8601(u.ConfirmationSentAt) + } + + if !u.ResetPasswordSentAt.IsZero() { + user.ResetPasswordSentAt = util.FormatISO8601(u.ResetPasswordSentAt) + } + + return user +} + // AppToAPIAppSensitive takes a db model application as a param, and returns a populated apitype application, or an error // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. diff --git a/internal/web/signup.go b/internal/web/signup.go index 691469dff..bc30749f8 100644 --- a/internal/web/signup.go +++ b/internal/web/signup.go @@ -108,9 +108,9 @@ func (m *Module) signupPOSTHandler(c *gin.Context) { } form.IP = signUpIP - // We have all the info we need, call account create + // We have all the info we need, call user+account create // (this will also trigger side effects like sending emails etc). - user, errWithCode := m.processor.Account().Create( + user, errWithCode := m.processor.User().Create( c.Request.Context(), // nil to use // instance app. -- cgit v1.2.3