diff options
author | 2021-10-31 15:46:23 +0100 | |
---|---|---|
committer | 2021-10-31 15:46:23 +0100 | |
commit | 2aaec827321ec711b98e13335899cf750f270105 (patch) | |
tree | bafa15a0c2a469adf97b2c437fdc31428516424e /internal/processing | |
parent | regenerate swagger docs (#293) (diff) | |
download | gotosocial-2aaec827321ec711b98e13335899cf750f270105.tar.xz |
smtp + email confirmation (#285)
* add smtp configuration
* add email confirm + reset templates
* add email sender to testrig
* flesh out the email sender interface
* go fmt
* golint
* update from field with more clarity
* tidy up the email formatting
* fix tests
* add email sender to processor
* tidy client api processing a bit
* further tidying in fromClientAPI
* pin new account to user
* send msg to processor on new account creation
* generate confirm email uri
* remove emailer from account processor again
* add processCreateAccountFromClientAPI
* move emailer accountprocessor => userprocessor
* add email sender to user processor
* SendConfirmEmail function
* add noop email sender
* use noop email sender in tests
* only assemble message if callback is not nil
* use noop email sender if no smtp host is defined
* minify email html before sending
* fix wrong email address
* email confirm test
* fmt
* serve web hndler
* add email confirm handler
* init test log properly on testrig
* log emails that *would* have been sent
* go fmt ./...
* unexport confirm email handler
* updatedAt
* test confirm email function
* don't allow tokens older than 7 days
* change error message a bit
* add basic smtp docs
* add a few more snippets
* typo
* add email sender to outbox tests
* don't use dutch wikipedia link
* don't minify email html
Diffstat (limited to 'internal/processing')
-rw-r--r-- | internal/processing/account/account_test.go | 5 | ||||
-rw-r--r-- | internal/processing/account/create.go | 20 | ||||
-rw-r--r-- | internal/processing/fromclientapi.go | 400 | ||||
-rw-r--r-- | internal/processing/processor.go | 19 | ||||
-rw-r--r-- | internal/processing/processor_test.go | 5 | ||||
-rw-r--r-- | internal/processing/user.go | 5 | ||||
-rw-r--r-- | internal/processing/user/emailconfirm.go | 132 | ||||
-rw-r--r-- | internal/processing/user/emailconfirm_test.go | 114 | ||||
-rw-r--r-- | internal/processing/user/user.go | 17 | ||||
-rw-r--r-- | internal/processing/user/user_test.go | 13 |
10 files changed, 559 insertions, 171 deletions
diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index f18290944..9bc65d376 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -48,6 +49,8 @@ type AccountStandardTestSuite struct { httpClient pub.HttpClient transportController transport.Controller federator federation.Federator + emailSender email.Sender + sentEmails map[string]string // standard suite models testTokens map[string]*gtsmodel.Token @@ -84,6 +87,8 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.httpClient = testrig.NewMockHTTPClient(nil) suite.transportController = testrig.NewTestTransportController(suite.httpClient, suite.db) suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) suite.accountProcessor = account.New(suite.db, suite.tc, suite.mediaHandler, suite.oauthServer, suite.fromClientAPIChan, suite.federator, suite.config) 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 index 56557af42..a13e84a78 100644 --- a/internal/processing/account/create.go +++ b/internal/processing/account/create.go @@ -21,10 +21,13 @@ package account import ( "context" "fmt" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/oauth2/v4" ) @@ -66,6 +69,23 @@ func (p *processor) Create(ctx context.Context, applicationToken oauth2.TokenInf return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) } + if user.Account == nil { + a, err := p.db.GetAccountByID(ctx, user.AccountID) + if err != nil { + return nil, fmt.Errorf("error getting new account from the database: %s", err) + } + user.Account = a + } + + // there are side effects for creating a new account (sending confirmation emails etc) + // so pass a message to the processor so that it can do it asynchronously + p.fromClientAPI <- messages.FromClientAPI{ + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityCreate, + GTSModel: user.Account, + OriginAccount: user.Account, + } + return &apimodel.Token{ AccessToken: accessToken.GetAccess(), TokenType: "Bearer", diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index 3054fbf57..db6f7382d 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -36,221 +36,303 @@ func (p *processor) ProcessFromClientAPI(ctx context.Context, clientMsg messages case ap.ActivityCreate: // CREATE switch clientMsg.APObjectType { + case ap.ObjectProfile, ap.ActorPerson: + // CREATE ACCOUNT/PROFILE + return p.processCreateAccountFromClientAPI(ctx, clientMsg) case ap.ObjectNote: // CREATE NOTE - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("note was not parseable as *gtsmodel.Status") - } - - if err := p.timelineStatus(ctx, status); err != nil { - return err - } - - if err := p.notifyStatus(ctx, status); err != nil { - return err - } - - if status.Federated { - return p.federateStatus(ctx, status) - } + return p.processCreateStatusFromClientAPI(ctx, clientMsg) case ap.ActivityFollow: // CREATE FOLLOW REQUEST - followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) - if !ok { - return errors.New("followrequest was not parseable as *gtsmodel.FollowRequest") - } - - if err := p.notifyFollowRequest(ctx, followRequest); err != nil { - return err - } - - return p.federateFollow(ctx, followRequest, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processCreateFollowRequestFromClientAPI(ctx, clientMsg) case ap.ActivityLike: // CREATE LIKE/FAVE - fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) - if !ok { - return errors.New("fave was not parseable as *gtsmodel.StatusFave") - } - - if err := p.notifyFave(ctx, fave); err != nil { - return err - } - - return p.federateFave(ctx, fave, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processCreateFaveFromClientAPI(ctx, clientMsg) case ap.ActivityAnnounce: // CREATE BOOST/ANNOUNCE - boostWrapperStatus, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("boost was not parseable as *gtsmodel.Status") - } - - if err := p.timelineStatus(ctx, boostWrapperStatus); err != nil { - return err - } - - if err := p.notifyAnnounce(ctx, boostWrapperStatus); err != nil { - return err - } - - return p.federateAnnounce(ctx, boostWrapperStatus, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processCreateAnnounceFromClientAPI(ctx, clientMsg) case ap.ActivityBlock: // CREATE BLOCK - block, ok := clientMsg.GTSModel.(*gtsmodel.Block) - if !ok { - return errors.New("block was not parseable as *gtsmodel.Block") - } - - // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa - if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { - return err - } - if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { - return err - } - - // TODO: same with notifications - // TODO: same with bookmarks - - return p.federateBlock(ctx, block) + return p.processCreateBlockFromClientAPI(ctx, clientMsg) } case ap.ActivityUpdate: // UPDATE switch clientMsg.APObjectType { case ap.ObjectProfile, ap.ActorPerson: // UPDATE ACCOUNT/PROFILE - account, ok := clientMsg.GTSModel.(*gtsmodel.Account) - if !ok { - return errors.New("account was not parseable as *gtsmodel.Account") - } - - return p.federateAccountUpdate(ctx, account, clientMsg.OriginAccount) + return p.processUpdateAccountFromClientAPI(ctx, clientMsg) } case ap.ActivityAccept: // ACCEPT switch clientMsg.APObjectType { case ap.ActivityFollow: // ACCEPT FOLLOW - follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) - if !ok { - return errors.New("accept was not parseable as *gtsmodel.Follow") - } - - if err := p.notifyFollow(ctx, follow, clientMsg.TargetAccount); err != nil { - return err - } - - return p.federateAcceptFollowRequest(ctx, follow) + return p.processAcceptFollowFromClientAPI(ctx, clientMsg) } case ap.ActivityReject: // REJECT switch clientMsg.APObjectType { case ap.ActivityFollow: // REJECT FOLLOW (request) - followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) - if !ok { - return errors.New("reject was not parseable as *gtsmodel.FollowRequest") - } - - return p.federateRejectFollowRequest(ctx, followRequest) + return p.processRejectFollowFromClientAPI(ctx, clientMsg) } case ap.ActivityUndo: // UNDO switch clientMsg.APObjectType { case ap.ActivityFollow: // UNDO FOLLOW - follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.Follow") - } - return p.federateUnfollow(ctx, follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processUndoFollowFromClientAPI(ctx, clientMsg) case ap.ActivityBlock: // UNDO BLOCK - block, ok := clientMsg.GTSModel.(*gtsmodel.Block) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.Block") - } - return p.federateUnblock(ctx, block) + return p.processUndoBlockFromClientAPI(ctx, clientMsg) case ap.ActivityLike: // UNDO LIKE/FAVE - fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.StatusFave") - } - return p.federateUnfave(ctx, fave, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processUndoFaveFromClientAPI(ctx, clientMsg) case ap.ActivityAnnounce: // UNDO ANNOUNCE/BOOST - boost, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.Status") - } - - if err := p.deleteStatusFromTimelines(ctx, boost); err != nil { - return err - } - - return p.federateUnannounce(ctx, boost, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.processUndoAnnounceFromClientAPI(ctx, clientMsg) } case ap.ActivityDelete: // DELETE switch clientMsg.APObjectType { case ap.ObjectNote: // DELETE STATUS/NOTE - statusToDelete, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("note was not parseable as *gtsmodel.Status") - } - - if statusToDelete.Account == nil { - statusToDelete.Account = clientMsg.OriginAccount - } - - // delete all attachments for this status - for _, a := range statusToDelete.AttachmentIDs { - if err := p.mediaProcessor.Delete(ctx, a); err != nil { - return err - } - } - - // delete all mentions for this status - for _, m := range statusToDelete.MentionIDs { - if err := p.db.DeleteByID(ctx, m, >smodel.Mention{}); err != nil { - return err - } - } - - // delete all notifications for this status - if err := p.db.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.Notification{}); err != nil { - return err - } - - // delete this status from any and all timelines - if err := p.deleteStatusFromTimelines(ctx, statusToDelete); err != nil { - return err - } - - return p.federateStatusDelete(ctx, statusToDelete) + return p.processDeleteStatusFromClientAPI(ctx, clientMsg) case ap.ObjectProfile, ap.ActorPerson: // DELETE ACCOUNT/PROFILE - - // the origin of the delete could be either a domain block, or an action by another (or this) account - var origin string - if domainBlock, ok := clientMsg.GTSModel.(*gtsmodel.DomainBlock); ok { - // origin is a domain block - origin = domainBlock.ID - } else { - // origin is whichever account caused this message - origin = clientMsg.OriginAccount.ID - } - return p.accountProcessor.Delete(ctx, clientMsg.TargetAccount, origin) + return p.processDeleteAccountFromClientAPI(ctx, clientMsg) } } return nil } +func (p *processor) processCreateAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + account, ok := clientMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return errors.New("account was not parseable as *gtsmodel.Account") + } + + // return if the account isn't from this domain + if account.Domain != "" { + return nil + } + + // get the user this account belongs to + user := >smodel.User{} + if err := p.db.GetWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, user); err != nil { + return err + } + + // email a confirmation to this user + return p.userProcessor.SendConfirmEmail(ctx, user, account.Username) +} + +func (p *processor) processCreateStatusFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + status, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } + + if err := p.timelineStatus(ctx, status); err != nil { + return err + } + + if err := p.notifyStatus(ctx, status); err != nil { + return err + } + + return p.federateStatus(ctx, status) +} + +func (p *processor) processCreateFollowRequestFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return errors.New("followrequest was not parseable as *gtsmodel.FollowRequest") + } + + if err := p.notifyFollowRequest(ctx, followRequest); err != nil { + return err + } + + return p.federateFollow(ctx, followRequest, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processCreateFaveFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("fave was not parseable as *gtsmodel.StatusFave") + } + + if err := p.notifyFave(ctx, fave); err != nil { + return err + } + + return p.federateFave(ctx, fave, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processCreateAnnounceFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + boostWrapperStatus, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("boost was not parseable as *gtsmodel.Status") + } + + if err := p.timelineStatus(ctx, boostWrapperStatus); err != nil { + return err + } + + if err := p.notifyAnnounce(ctx, boostWrapperStatus); err != nil { + return err + } + + return p.federateAnnounce(ctx, boostWrapperStatus, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processCreateBlockFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + block, ok := clientMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return errors.New("block was not parseable as *gtsmodel.Block") + } + + // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa + if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { + return err + } + if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { + return err + } + + // TODO: same with notifications + // TODO: same with bookmarks + + return p.federateBlock(ctx, block) +} + +func (p *processor) processUpdateAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + account, ok := clientMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return errors.New("account was not parseable as *gtsmodel.Account") + } + + return p.federateAccountUpdate(ctx, account, clientMsg.OriginAccount) +} + +func (p *processor) processAcceptFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("accept was not parseable as *gtsmodel.Follow") + } + + if err := p.notifyFollow(ctx, follow, clientMsg.TargetAccount); err != nil { + return err + } + + return p.federateAcceptFollowRequest(ctx, follow) +} + +func (p *processor) processRejectFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return errors.New("reject was not parseable as *gtsmodel.FollowRequest") + } + + return p.federateRejectFollowRequest(ctx, followRequest) +} + +func (p *processor) processUndoFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Follow") + } + return p.federateUnfollow(ctx, follow, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processUndoBlockFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + block, ok := clientMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Block") + } + return p.federateUnblock(ctx, block) +} + +func (p *processor) processUndoFaveFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.StatusFave") + } + return p.federateUnfave(ctx, fave, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processUndoAnnounceFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + boost, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Status") + } + + if err := p.deleteStatusFromTimelines(ctx, boost); err != nil { + return err + } + + return p.federateUnannounce(ctx, boost, clientMsg.OriginAccount, clientMsg.TargetAccount) +} + +func (p *processor) processDeleteStatusFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + statusToDelete, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } + + if statusToDelete.Account == nil { + statusToDelete.Account = clientMsg.OriginAccount + } + + // delete all attachments for this status + for _, a := range statusToDelete.AttachmentIDs { + if err := p.mediaProcessor.Delete(ctx, a); err != nil { + return err + } + } + + // delete all mentions for this status + for _, m := range statusToDelete.MentionIDs { + if err := p.db.DeleteByID(ctx, m, >smodel.Mention{}); err != nil { + return err + } + } + + // delete all notifications for this status + if err := p.db.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.Notification{}); err != nil { + return err + } + + // delete this status from any and all timelines + if err := p.deleteStatusFromTimelines(ctx, statusToDelete); err != nil { + return err + } + + return p.federateStatusDelete(ctx, statusToDelete) +} + +func (p *processor) processDeleteAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + // the origin of the delete could be either a domain block, or an action by another (or this) account + var origin string + if domainBlock, ok := clientMsg.GTSModel.(*gtsmodel.DomainBlock); ok { + // origin is a domain block + origin = domainBlock.ID + } else { + // origin is whichever account caused this message + origin = clientMsg.OriginAccount.ID + } + return p.accountProcessor.Delete(ctx, clientMsg.TargetAccount, origin) +} + // TODO: move all the below functions into federation.Federator func (p *processor) federateStatus(ctx context.Context, status *gtsmodel.Status) error { + // do nothing if the status shouldn't be federated + if !status.Federated { + return nil + } + if status.Account == nil { statusAccount, err := p.db.GetAccountByID(ctx, status.AccountID) if err != nil { diff --git a/internal/processing/processor.go b/internal/processing/processor.go index d0a636b27..00ee0e51c 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -28,6 +28,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -179,6 +180,9 @@ type Processor interface { // UserChangePassword changes the password for the given user, with the given form. UserChangePassword(ctx context.Context, authed *oauth.Auth, form *apimodel.PasswordChangeRequest) gtserror.WithCode + // UserConfirmEmail confirms an email address using the given token. + // The user belonging to the confirmed email is also returned. + UserConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) /* FEDERATION API-FACING PROCESSING FUNCTIONS @@ -252,8 +256,17 @@ type processor struct { federationProcessor federationProcessor.Processor } -// NewProcessor returns a new Processor that uses the given federator -func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage *kv.KVStore, timelineManager timeline.Manager, db db.DB) Processor { +// NewProcessor returns a new Processor. +func NewProcessor( + config *config.Config, + tc typeutils.TypeConverter, + federator federation.Federator, + oauthServer oauth.Server, + mediaHandler media.Handler, + storage *kv.KVStore, + timelineManager timeline.Manager, + db db.DB, + emailSender email.Sender) Processor { fromClientAPI := make(chan messages.FromClientAPI, 1000) fromFederator := make(chan messages.FromFederator, 1000) @@ -262,7 +275,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f accountProcessor := account.New(db, tc, mediaHandler, oauthServer, fromClientAPI, federator, config) adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI, config) mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage, config) - userProcessor := user.New(db, config) + userProcessor := user.New(db, emailSender, config) federationProcessor := federationProcessor.New(db, tc, config, federator, fromFederator) return &processor{ diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index beae5dba0..868c96bfe 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -31,6 +31,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -54,6 +55,7 @@ type ProcessingStandardTestSuite struct { oauthServer oauth.Server mediaHandler media.Handler timelineManager timeline.Manager + emailSender email.Sender // standard suite models testTokens map[string]*gtsmodel.Token @@ -219,8 +221,9 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.timelineManager = testrig.NewTestTimelineManager(suite.db) + suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) - suite.processor = processing.NewProcessor(suite.config, suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaHandler, suite.storage, suite.timelineManager, suite.db) + suite.processor = processing.NewProcessor(suite.config, suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaHandler, suite.storage, suite.timelineManager, suite.db, suite.emailSender) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../testrig/media") diff --git a/internal/processing/user.go b/internal/processing/user.go index a5fca53dd..1507267e8 100644 --- a/internal/processing/user.go +++ b/internal/processing/user.go @@ -23,9 +23,14 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) func (p *processor) UserChangePassword(ctx context.Context, authed *oauth.Auth, form *apimodel.PasswordChangeRequest) gtserror.WithCode { return p.userProcessor.ChangePassword(ctx, authed.User, form.OldPassword, form.NewPassword) } + +func (p *processor) UserConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { + return p.userProcessor.ConfirmEmail(ctx, token) +} diff --git a/internal/processing/user/emailconfirm.go b/internal/processing/user/emailconfirm.go new file mode 100644 index 000000000..1f9cb0a10 --- /dev/null +++ b/internal/processing/user/emailconfirm.go @@ -0,0 +1,132 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +var ( + oneWeek = 168 * time.Hour +) + +func (p *processor) SendConfirmEmail(ctx context.Context, user *gtsmodel.User, username string) error { + if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { + // user has already confirmed this email address, so there's nothing to do + return nil + } + + // We need a token and a link for the user to click on. + // We'll use a uuid as our token since it's basically impossible to guess. + // From the uuid package we use (which uses crypto/rand under the hood): + // Randomly generated UUIDs have 122 random bits. One's annual risk of being + // hit by a meteorite is estimated to be one chance in 17 billion, that + // means the probability is about 0.00000000006 (6 × 10−11), + // equivalent to the odds of creating a few tens of trillions of UUIDs in a + // year and having one duplicate. + confirmationToken := uuid.NewString() + confirmationLink := util.GenerateURIForEmailConfirm(p.config.Protocol, p.config.Host, confirmationToken) + + // pull our instance entry from the database so we can greet the user nicely in the email + instance := >smodel.Instance{} + if err := p.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: p.config.Host}}, instance); err != nil { + return fmt.Errorf("SendConfirmEmail: error getting instance: %s", err) + } + + // assemble the email contents and send the email + confirmData := email.ConfirmData{ + Username: username, + InstanceURL: instance.URI, + InstanceName: instance.Title, + ConfirmLink: confirmationLink, + } + if err := p.emailSender.SendConfirmEmail(user.UnconfirmedEmail, confirmData); err != nil { + return fmt.Errorf("SendConfirmEmail: error sending to email address %s belonging to user %s: %s", user.UnconfirmedEmail, username, err) + } + + // email sent, now we need to update the user entry with the token we just sent them + user.ConfirmationSentAt = time.Now() + user.ConfirmationToken = confirmationToken + user.LastEmailedAt = time.Now() + user.UpdatedAt = time.Now() + + if err := p.db.UpdateByPrimaryKey(ctx, user); err != nil { + return fmt.Errorf("SendConfirmEmail: error updating user entry after email sent: %s", err) + } + + return nil +} + +func (p *processor) ConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { + if token == "" { + return nil, gtserror.NewErrorNotFound(errors.New("no token provided")) + } + + user := >smodel.User{} + if err := p.db.GetWhere(ctx, []db.Where{{Key: "confirmation_token", Value: token}}, user); err != nil { + if err == db.ErrNoEntries { + return nil, gtserror.NewErrorNotFound(err) + } + return nil, gtserror.NewErrorInternalError(err) + } + + if user.Account == nil { + a, err := p.db.GetAccountByID(ctx, user.AccountID) + if err != nil { + return nil, gtserror.NewErrorNotFound(err) + } + user.Account = a + } + + if !user.Account.SuspendedAt.IsZero() { + return nil, gtserror.NewErrorForbidden(fmt.Errorf("ConfirmEmail: account %s is suspended", user.AccountID)) + } + + if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { + // no pending email confirmations so just return OK + return user, nil + } + + if user.ConfirmationSentAt.Before(time.Now().Add(-oneWeek)) { + return nil, gtserror.NewErrorForbidden(errors.New("ConfirmEmail: confirmation token expired")) + } + + // mark the user's email address as confirmed + remove the unconfirmed address and the token + user.Email = user.UnconfirmedEmail + user.UnconfirmedEmail = "" + user.ConfirmedAt = time.Now() + user.ConfirmationToken = "" + user.UpdatedAt = time.Now() + + if err := p.db.UpdateByPrimaryKey(ctx, user); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return user, nil +} diff --git a/internal/processing/user/emailconfirm_test.go b/internal/processing/user/emailconfirm_test.go new file mode 100644 index 000000000..40d5956aa --- /dev/null +++ b/internal/processing/user/emailconfirm_test.go @@ -0,0 +1,114 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type EmailConfirmTestSuite struct { + UserStandardTestSuite +} + +func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() { + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought) + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Time{} + user.ConfirmationToken = "" + + err := suite.user.SendConfirmEmail(context.Background(), user, "the_mighty_zork") + suite.NoError(err) + + // zork should have an email now + suite.Len(suite.sentEmails, 1) + email, ok := suite.sentEmails["some.email@example.org"] + suite.True(ok) + + // a token should be set on zork + token := user.ConfirmationToken + suite.NotEmpty(token) + + // email should contain the token + emailShould := fmt.Sprintf("Subject: GoToSocial Email Confirmation\r\nFrom: GoToSocial <test@example.org>\r\nTo: some.email@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n<!DOCTYPE html>\n<html>\n </head>\n <body>\n <div>\n <h1>\n Hello the_mighty_zork!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because you've requested an account on <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n <p>\n We just need to confirm that this is your email address. To confirm your email, <a href=\"http://localhost:8080/confirm_email?token=%s\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n http://localhost:8080/confirm_email?token=%s\n </code>\n </p>\n </div>\n <div>\n <p>\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n </div>\n </body>\n</html>\r\n", token, token) + suite.Equal(emailShould, email) + + // confirmationSentAt should be recent + suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmail() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 5 minutes ago + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-5 * time.Minute) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByPrimaryKey(ctx, user) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.NoError(errWithCode) + + // email should now be confirmed and token cleared + suite.Equal("some.email@example.org", updatedUser.Email) + suite.Empty(updatedUser.UnconfirmedEmail) + suite.Empty(updatedUser.ConfirmationToken) + suite.WithinDuration(updatedUser.ConfirmedAt, time.Now(), 1*time.Minute) + suite.WithinDuration(updatedUser.UpdatedAt, time.Now(), 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 8 days ago + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-192 * time.Hour) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByPrimaryKey(ctx, user) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.Nil(updatedUser) + suite.EqualError(errWithCode, "ConfirmEmail: confirmation token expired") +} + +func TestEmailConfirmTestSuite(t *testing.T) { + suite.Run(t, &EmailConfirmTestSuite{}) +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go index c572becc2..73cdb4901 100644 --- a/internal/processing/user/user.go +++ b/internal/processing/user/user.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -32,17 +33,23 @@ type Processor interface { // ChangePassword changes the specified user's password from old => new, // or returns an error if the new password is too weak, or the old password is incorrect. ChangePassword(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode + // SendConfirmEmail sends a 'confirm-your-email-address' type email to a user. + SendConfirmEmail(ctx context.Context, user *gtsmodel.User, username string) error + // ConfirmEmail confirms an email address using the given token. + ConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) } type processor struct { - config *config.Config - db db.DB + config *config.Config + emailSender email.Sender + db db.DB } // New returns a new user processor -func New(db db.DB, config *config.Config) Processor { +func New(db db.DB, emailSender email.Sender, config *config.Config) Processor { return &processor{ - config: config, - db: db, + config: config, + emailSender: emailSender, + db: db, } } diff --git a/internal/processing/user/user_test.go b/internal/processing/user/user_test.go index 4f18e03c2..5c3cd7597 100644 --- a/internal/processing/user/user_test.go +++ b/internal/processing/user/user_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing/user" "github.com/superseriousbusiness/gotosocial/testrig" @@ -29,11 +30,14 @@ import ( type UserStandardTestSuite struct { suite.Suite - config *config.Config - db db.DB + config *config.Config + emailSender email.Sender + db db.DB testUsers map[string]*gtsmodel.User + sentEmails map[string]string + user user.Processor } @@ -41,8 +45,11 @@ func (suite *UserStandardTestSuite) SetupTest() { testrig.InitTestLog() suite.config = testrig.NewTestConfig() suite.db = testrig.NewTestDB() + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) suite.testUsers = testrig.NewTestUsers() - suite.user = user.New(suite.db, suite.config) + + suite.user = user.New(suite.db, suite.emailSender, suite.config) testrig.StandardDBSetup(suite.db, nil) } |