diff options
author | 2021-10-31 15:46:23 +0100 | |
---|---|---|
committer | 2021-10-31 15:46:23 +0100 | |
commit | 2aaec827321ec711b98e13335899cf750f270105 (patch) | |
tree | bafa15a0c2a469adf97b2c437fdc31428516424e /internal | |
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')
47 files changed, 1272 insertions, 396 deletions
diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go index f33fd735f..8e9bc132a 100644 --- a/internal/api/client/account/account_test.go +++ b/internal/api/client/account/account_test.go @@ -12,6 +12,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/account" "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/oauth" @@ -23,12 +24,14 @@ import ( type AccountStandardTestSuite struct { // standard suite interfaces suite.Suite - config *config.Config - db db.DB - tc typeutils.TypeConverter - storage *kv.KVStore - federator federation.Federator - processor processing.Processor + config *config.Config + db db.DB + tc typeutils.TypeConverter + storage *kv.KVStore + federator federation.Federator + processor processing.Processor + emailSender email.Sender + sentEmails map[string]string // standard suite models testTokens map[string]*gtsmodel.Token @@ -59,7 +62,9 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.storage = testrig.NewTestStorage() testrig.InitTestLog() suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.accountModule = account.New(suite.config, suite.processor).(*account.Module) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go index 60f9740b6..ab5cfadf2 100644 --- a/internal/api/client/fileserver/servefile_test.go +++ b/internal/api/client/fileserver/servefile_test.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" "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 ServeFileTestSuite struct { processor processing.Processor mediaHandler media.Handler oauthServer oauth.Server + emailSender email.Sender // standard suite models testTokens map[string]*gtsmodel.Token @@ -78,7 +80,9 @@ func (suite *ServeFileTestSuite) SetupSuite() { testrig.InitTestLog() suite.storage = testrig.NewTestStorage() suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) diff --git a/internal/api/client/followrequest/followrequest_test.go b/internal/api/client/followrequest/followrequest_test.go index 32b3048a1..94f6cbb0e 100644 --- a/internal/api/client/followrequest/followrequest_test.go +++ b/internal/api/client/followrequest/followrequest_test.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" "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/oauth" @@ -38,11 +39,12 @@ import ( type FollowRequestStandardTestSuite struct { suite.Suite - config *config.Config - db db.DB - storage *kv.KVStore - federator federation.Federator - processor processing.Processor + config *config.Config + db db.DB + storage *kv.KVStore + federator federation.Federator + processor processing.Processor + emailSender email.Sender // standard suite models testTokens map[string]*gtsmodel.Token @@ -73,7 +75,8 @@ func (suite *FollowRequestStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.followRequestModule = followrequest.New(suite.config, suite.processor).(*followrequest.Module) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 1ee58e798..2bc598499 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -37,6 +37,7 @@ import ( "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/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -56,6 +57,7 @@ type MediaCreateTestSuite struct { tc typeutils.TypeConverter mediaHandler media.Handler oauthServer oauth.Server + emailSender email.Sender processor processing.Processor // standard suite models @@ -84,7 +86,8 @@ func (suite *MediaCreateTestSuite) SetupSuite() { suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) // setup module being tested suite.mediaModule = mediamodule.New(suite.config, suite.processor).(*mediamodule.Module) diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go index 0c0c391b5..fa8c9823e 100644 --- a/internal/api/client/status/status_test.go +++ b/internal/api/client/status/status_test.go @@ -24,22 +24,24 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "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/processing" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" ) -// nolint type StatusStandardTestSuite struct { // standard suite interfaces suite.Suite - config *config.Config - db db.DB - tc typeutils.TypeConverter - federator federation.Federator - processor processing.Processor - storage *kv.KVStore + config *config.Config + db db.DB + tc typeutils.TypeConverter + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *kv.KVStore // standard suite models testTokens map[string]*gtsmodel.Token @@ -53,3 +55,32 @@ type StatusStandardTestSuite struct { // module being tested statusModule *status.Module } + +func (suite *StatusStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *StatusStandardTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewTestStorage() + testrig.InitTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) + suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *StatusStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/client/status/statusboost_test.go b/internal/api/client/status/statusboost_test.go index 2a8db1f4d..b5a377565 100644 --- a/internal/api/client/status/statusboost_test.go +++ b/internal/api/client/status/statusboost_test.go @@ -30,40 +30,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusBoostTestSuite struct { StatusStandardTestSuite } -func (suite *StatusBoostTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusBoostTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusBoostTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - func (suite *StatusBoostTestSuite) TestPostBoost() { t := suite.testTokens["local_account_1"] diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go index e7c1125ce..776b25769 100644 --- a/internal/api/client/status/statuscreate_test.go +++ b/internal/api/client/status/statuscreate_test.go @@ -43,34 +43,6 @@ type StatusCreateTestSuite struct { StatusStandardTestSuite } -func (suite *StatusCreateTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusCreateTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - var statusWithLinksAndTags = `#test alright, should be able to post #links with fragments in them now, let's see........ https://docs.gotosocial.org/en/latest/user_guide/posts/#links diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go index 3fafe44e4..5b877a291 100644 --- a/internal/api/client/status/statusfave_test.go +++ b/internal/api/client/status/statusfave_test.go @@ -33,40 +33,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusFaveTestSuite struct { StatusStandardTestSuite } -func (suite *StatusFaveTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusFaveTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusFaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - // fave a status func (suite *StatusFaveTestSuite) TestPostFave() { diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go index 2958379fa..0f10d8449 100644 --- a/internal/api/client/status/statusfavedby_test.go +++ b/internal/api/client/status/statusfavedby_test.go @@ -33,40 +33,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusFavedByTestSuite struct { StatusStandardTestSuite } -func (suite *StatusFavedByTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusFavedByTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusFavedByTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - func (suite *StatusFavedByTestSuite) TestGetFavedBy() { t := suite.testTokens["local_account_2"] oauthToken := oauth.DBTokenToToken(t) diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go index 17326b466..1439add5b 100644 --- a/internal/api/client/status/statusget_test.go +++ b/internal/api/client/status/statusget_test.go @@ -22,41 +22,12 @@ import ( "testing" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusGetTestSuite struct { StatusStandardTestSuite } -func (suite *StatusGetTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusGetTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusGetTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - func TestStatusGetTestSuite(t *testing.T) { suite.Run(t, new(StatusGetTestSuite)) } diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go index e99569e0a..0809840da 100644 --- a/internal/api/client/status/statusunfave_test.go +++ b/internal/api/client/status/statusunfave_test.go @@ -33,40 +33,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusUnfaveTestSuite struct { StatusStandardTestSuite } -func (suite *StatusUnfaveTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -func (suite *StatusUnfaveTestSuite) SetupTest() { - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewTestStorage() - testrig.InitTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) - suite.statusModule = status.New(suite.config, suite.processor).(*status.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") -} - -func (suite *StatusUnfaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - // unfave a status func (suite *StatusUnfaveTestSuite) TestPostUnfave() { diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go index 02f10b5ae..fe420b5c9 100644 --- a/internal/api/client/user/user_test.go +++ b/internal/api/client/user/user_test.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/user" "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/processing" @@ -33,12 +34,13 @@ import ( type UserStandardTestSuite struct { suite.Suite - config *config.Config - db db.DB - tc typeutils.TypeConverter - federator federation.Federator - processor processing.Processor - storage *kv.KVStore + config *config.Config + db db.DB + tc typeutils.TypeConverter + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *kv.KVStore testTokens map[string]*gtsmodel.Token testClients map[string]*gtsmodel.Client @@ -46,6 +48,8 @@ type UserStandardTestSuite struct { testUsers map[string]*gtsmodel.User testAccounts map[string]*gtsmodel.Account + sentEmails map[string]string + userModule *user.Module } @@ -61,7 +65,9 @@ func (suite *UserStandardTestSuite) SetupTest() { testrig.InitTestLog() suite.tc = testrig.NewTestTypeConverter(suite.db) suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.userModule = user.New(suite.config, suite.processor).(*user.Module) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go index 554f3d729..3f02affdb 100644 --- a/internal/api/s2s/user/inboxpost_test.go +++ b/internal/api/s2s/user/inboxpost_test.go @@ -85,7 +85,8 @@ func (suite *InboxPostTestSuite) TestPostBlock() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -184,7 +185,8 @@ func (suite *InboxPostTestSuite) TestPostUnblock() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -273,7 +275,8 @@ func (suite *InboxPostTestSuite) TestPostUpdate() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -391,7 +394,8 @@ func (suite *InboxPostTestSuite) TestPostDelete() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) err = processor.Start(context.Background()) suite.NoError(err) userModule := user.New(suite.config, processor).(*user.Module) diff --git a/internal/api/s2s/user/outboxget_test.go b/internal/api/s2s/user/outboxget_test.go index f1818683e..4f5ea3f17 100644 --- a/internal/api/s2s/user/outboxget_test.go +++ b/internal/api/s2s/user/outboxget_test.go @@ -46,7 +46,8 @@ func (suite *OutboxGetTestSuite) TestGetOutbox() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -99,7 +100,8 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -152,7 +154,8 @@ func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request diff --git a/internal/api/s2s/user/repliesget_test.go b/internal/api/s2s/user/repliesget_test.go index 44717ad42..32cd0c366 100644 --- a/internal/api/s2s/user/repliesget_test.go +++ b/internal/api/s2s/user/repliesget_test.go @@ -49,7 +49,8 @@ func (suite *RepliesGetTestSuite) TestGetReplies() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -108,7 +109,8 @@ func (suite *RepliesGetTestSuite) TestGetRepliesNext() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request @@ -170,7 +172,8 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go index 268523724..fc4232bde 100644 --- a/internal/api/s2s/user/user_test.go +++ b/internal/api/s2s/user/user_test.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/security" "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/processing" @@ -39,6 +40,7 @@ type UserStandardTestSuite struct { db db.DB tc typeutils.TypeConverter federator federation.Federator + emailSender email.Sender processor processing.Processor storage *kv.KVStore securityModule *security.Module @@ -75,7 +77,8 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.storage = testrig.NewTestStorage() testrig.InitTestLog() suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.userModule = user.New(suite.config, suite.processor).(*user.Module) suite.securityModule = security.New(suite.config, suite.db).(*security.Module) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go index d8d1df148..68f16fc76 100644 --- a/internal/api/s2s/user/userget_test.go +++ b/internal/api/s2s/user/userget_test.go @@ -47,7 +47,8 @@ func (suite *UserGetTestSuite) TestGetUser() { tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db) federator := testrig.NewTestFederator(suite.db, tc, suite.storage) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender) userModule := user.New(suite.config, processor).(*user.Module) // setup request diff --git a/internal/api/s2s/webfinger/webfinger_test.go b/internal/api/s2s/webfinger/webfinger_test.go index ee1ff6607..69fd32408 100644 --- a/internal/api/s2s/webfinger/webfinger_test.go +++ b/internal/api/s2s/webfinger/webfinger_test.go @@ -30,6 +30,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/security" "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/processing" @@ -44,6 +45,7 @@ type WebfingerStandardTestSuite struct { db db.DB tc typeutils.TypeConverter federator federation.Federator + emailSender email.Sender processor processing.Processor storage *kv.KVStore securityModule *security.Module @@ -78,7 +80,8 @@ func (suite *WebfingerStandardTestSuite) SetupTest() { suite.storage = testrig.NewTestStorage() testrig.InitTestLog() suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender) suite.webfingerModule = webfinger.New(suite.config, suite.processor).(*webfinger.Module) suite.securityModule = security.New(suite.config, suite.db).(*security.Module) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/api/s2s/webfinger/webfingerget_test.go b/internal/api/s2s/webfinger/webfingerget_test.go index 3b38c5a7e..19c637929 100644 --- a/internal/api/s2s/webfinger/webfingerget_test.go +++ b/internal/api/s2s/webfinger/webfingerget_test.go @@ -65,7 +65,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUser() { func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { suite.config.Host = "gts.example.org" suite.config.AccountDomain = "example.org" - suite.processor = processing.NewProcessor(suite.config, suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db) + suite.processor = processing.NewProcessor(suite.config, suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db, suite.emailSender) suite.webfingerModule = webfinger.New(suite.config, suite.processor).(*webfinger.Module) targetAccount := accountDomainAccount() @@ -97,7 +97,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHo func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() { suite.config.Host = "gts.example.org" suite.config.AccountDomain = "example.org" - suite.processor = processing.NewProcessor(suite.config, suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db) + suite.processor = processing.NewProcessor(suite.config, suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db, suite.emailSender) suite.webfingerModule = webfinger.New(suite.config, suite.processor).(*webfinger.Module) targetAccount := accountDomainAccount() diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go index 107d84d2c..3837952c7 100644 --- a/internal/cliactions/server/server.go +++ b/internal/cliactions/server/server.go @@ -36,6 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/cliactions" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" "github.com/superseriousbusiness/gotosocial/internal/gotosocial" @@ -76,22 +77,40 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config) err return fmt.Errorf("error creating router: %s", err) } + // build converters and util + typeConverter := typeutils.NewConverter(c, dbService) + timelineManager := timelineprocessing.NewManager(dbService, typeConverter, c) + // Open the storage backend storage, err := kv.OpenFile(c.StorageConfig.BasePath, nil) if err != nil { return fmt.Errorf("error creating storage backend: %s", err) } - // build converters and util - typeConverter := typeutils.NewConverter(c, dbService) - timelineManager := timelineprocessing.NewManager(dbService, typeConverter, c) - // build backend handlers mediaHandler := media.New(c, dbService, storage) oauthServer := oauth.New(ctx, dbService) transportController := transport.NewController(c, dbService, &federation.Clock{}, http.DefaultClient) federator := federation.NewFederator(dbService, federatingDB, transportController, c, typeConverter, mediaHandler) - processor := processing.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storage, timelineManager, dbService) + + // decide whether to create a noop email sender (won't send emails) or a real one + var emailSender email.Sender + if c.SMTPConfig.Host != "" { + // host is defined so create a proper sender + emailSender, err = email.NewSender(c) + if err != nil { + return fmt.Errorf("error creating email sender: %s", err) + } + } else { + // no host is defined so create a noop sender + emailSender, err = email.NewNoopSender(c.TemplateConfig.BaseDir, nil) + if err != nil { + return fmt.Errorf("error creating noop email sender: %s", err) + } + } + + // create and start the message processor using the other services we've created so far + processor := processing.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storage, timelineManager, dbService, emailSender) if err := processor.Start(ctx); err != nil { return fmt.Errorf("error starting processor: %s", err) } diff --git a/internal/cliactions/testrig/testrig.go b/internal/cliactions/testrig/testrig.go index 2993d8613..2cdbfaf52 100644 --- a/internal/cliactions/testrig/testrig.go +++ b/internal/cliactions/testrig/testrig.go @@ -44,6 +44,8 @@ import ( // Start creates and starts a gotosocial testrig server var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config) error { + testrig.InitTestLog() + c := testrig.NewTestConfig() dbService := testrig.NewTestDB() testrig.StandardDBSetup(dbService, nil) @@ -62,7 +64,9 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config) err }), dbService) federator := testrig.NewTestFederator(dbService, transportController, storageBackend) - processor := testrig.NewTestProcessor(dbService, storageBackend, federator) + emailSender := testrig.NewEmailSender("./web/template/", nil) + + processor := testrig.NewTestProcessor(dbService, storageBackend, federator, emailSender) if err := processor.Start(ctx); err != nil { return fmt.Errorf("error starting processor: %s", err) } diff --git a/internal/config/config.go b/internal/config/config.go index bb789b7d2..7a3a92af9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,6 +63,7 @@ type Config struct { StatusesConfig *StatusesConfig `yaml:"statuses"` LetsEncryptConfig *LetsEncryptConfig `yaml:"letsEncrypt"` OIDCConfig *OIDCConfig `yaml:"oidc"` + SMTPConfig *SMTPConfig `yaml:"smtp"` /* Not parsed from .yaml configuration file. @@ -95,6 +96,7 @@ func Empty() *Config { StatusesConfig: &StatusesConfig{}, LetsEncryptConfig: &LetsEncryptConfig{}, OIDCConfig: &OIDCConfig{}, + SMTPConfig: &SMTPConfig{}, AccountCLIFlags: make(map[string]string), ExportCLIFlags: make(map[string]string), } @@ -318,6 +320,27 @@ func (c *Config) ParseCLIFlags(f KeyedFlags, version string) error { c.OIDCConfig.Scopes = f.StringSlice(fn.OIDCScopes) } + // smtp flags + if c.SMTPConfig.Host == "" || f.IsSet(fn.SMTPHost) { + c.SMTPConfig.Host = f.String(fn.SMTPHost) + } + + if c.SMTPConfig.Port == 0 || f.IsSet(fn.SMTPPort) { + c.SMTPConfig.Port = f.Int(fn.SMTPPort) + } + + if c.SMTPConfig.Username == "" || f.IsSet(fn.SMTPUsername) { + c.SMTPConfig.Username = f.String(fn.SMTPUsername) + } + + if c.SMTPConfig.Password == "" || f.IsSet(fn.SMTPPassword) { + c.SMTPConfig.Password = f.String(fn.SMTPPassword) + } + + if c.SMTPConfig.From == "" || f.IsSet(fn.SMTPFrom) { + c.SMTPConfig.From = f.String(fn.SMTPFrom) + } + // command-specific flags // admin account CLI flags @@ -399,6 +422,12 @@ type Flags struct { OIDCClientID string OIDCClientSecret string OIDCScopes string + + SMTPHost string + SMTPPort string + SMTPUsername string + SMTPPassword string + SMTPFrom string } // Defaults contains all the default values for a gotosocial config @@ -458,6 +487,12 @@ type Defaults struct { OIDCClientID string OIDCClientSecret string OIDCScopes []string + + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPFrom string } // GetFlagNames returns a struct containing the names of the various flags used for @@ -518,6 +553,12 @@ func GetFlagNames() Flags { OIDCClientID: "oidc-client-id", OIDCClientSecret: "oidc-client-secret", OIDCScopes: "oidc-scopes", + + SMTPHost: "smtp-host", + SMTPPort: "smtp-port", + SMTPUsername: "smtp-username", + SMTPPassword: "smtp-password", + SMTPFrom: "smtp-from", } } @@ -579,5 +620,11 @@ func GetEnvNames() Flags { OIDCClientID: "GTS_OIDC_CLIENT_ID", OIDCClientSecret: "GTS_OIDC_CLIENT_SECRET", OIDCScopes: "GTS_OIDC_SCOPES", + + SMTPHost: "SMTP_HOST", + SMTPPort: "SMTP_PORT", + SMTPUsername: "SMTP_USERNAME", + SMTPPassword: "SMTP_PASSWORD", + SMTPFrom: "SMTP_FROM", } } diff --git a/internal/config/default.go b/internal/config/default.go index ad171a376..a04a5899b 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -67,6 +67,13 @@ func TestDefault() *Config { ClientSecret: defaults.OIDCClientSecret, Scopes: defaults.OIDCScopes, }, + SMTPConfig: &SMTPConfig{ + Host: defaults.SMTPHost, + Port: defaults.SMTPPort, + Username: defaults.SMTPUsername, + Password: defaults.SMTPPassword, + From: defaults.SMTPFrom, + }, } } @@ -134,6 +141,13 @@ func Default() *Config { ClientSecret: defaults.OIDCClientSecret, Scopes: defaults.OIDCScopes, }, + SMTPConfig: &SMTPConfig{ + Host: defaults.SMTPHost, + Port: defaults.SMTPPort, + Username: defaults.SMTPUsername, + Password: defaults.SMTPPassword, + From: defaults.SMTPFrom, + }, } } @@ -195,6 +209,12 @@ func GetDefaults() Defaults { OIDCClientID: "", OIDCClientSecret: "", OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, + + SMTPHost: "", + SMTPPort: 0, + SMTPUsername: "", + SMTPPassword: "", + SMTPFrom: "GoToSocial", } } @@ -253,5 +273,11 @@ func GetTestDefaults() Defaults { OIDCClientID: "", OIDCClientSecret: "", OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, + + SMTPHost: "", + SMTPPort: 0, + SMTPUsername: "", + SMTPPassword: "", + SMTPFrom: "GoToSocial", } } diff --git a/internal/config/smtp.go b/internal/config/smtp.go new file mode 100644 index 000000000..daa4967bf --- /dev/null +++ b/internal/config/smtp.go @@ -0,0 +1,33 @@ +/* + 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 config + +// SMTPConfig holds configuration for sending emails using the smtp protocol. +type SMTPConfig struct { + // Host of the smtp server. + Host string `yaml:"host"` + // Port of the smtp server. + Port int `yaml:"port"` + // Username to use when authenticating with the smtp server. + Username string `yaml:"username"` + // Password to use when authenticating with the smtp server. + Password string `yaml:"password"` + // From address to use when sending emails. + From string `yaml:"from"` +} diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go index 79ef4d142..4381c1de8 100644 --- a/internal/db/bundb/admin.go +++ b/internal/db/bundb/admin.go @@ -24,12 +24,13 @@ import ( "crypto/rsa" "database/sql" "fmt" - "github.com/sirupsen/logrus" "net" "net/mail" "strings" "time" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -145,6 +146,7 @@ func (a *adminDB) NewSignup(ctx context.Context, username string, reason string, u := >smodel.User{ ID: newUserID, AccountID: acct.ID, + Account: acct, EncryptedPassword: string(pw), SignUpIP: signUpIP.To4(), Locale: locale, diff --git a/internal/email/confirm.go b/internal/email/confirm.go new file mode 100644 index 000000000..78f661fe1 --- /dev/null +++ b/internal/email/confirm.go @@ -0,0 +1,53 @@ +/* + 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 email + +import ( + "bytes" + "net/smtp" +) + +const ( + confirmTemplate = "email_confirm.tmpl" + confirmSubject = "Subject: GoToSocial Email Confirmation" +) + +func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil { + return err + } + confirmBody := buf.String() + + msg := assembleMessage(confirmSubject, confirmBody, toAddress, s.from) + return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg) +} + +// ConfirmData represents data passed into the confirm email address template. +type ConfirmData struct { + // Username to be addressed. + Username string + // URL of the instance to present to the receiver. + InstanceURL string + // 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 + ConfirmLink string +} diff --git a/internal/email/email.go b/internal/email/email_test.go index 3d6a9dd2d..4e2619546 100644 --- a/internal/email/email.go +++ b/internal/email/email_test.go @@ -16,5 +16,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -// Package email provides a service for interacting with an SMTP server -package email +package email_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmailTestSuite struct { + suite.Suite + + sender email.Sender + + sentEmails map[string]string +} + +func (suite *EmailTestSuite) SetupTest() { + testrig.InitTestLog() + suite.sentEmails = make(map[string]string) + suite.sender = testrig.NewEmailSender("../../web/template/", suite.sentEmails) +} diff --git a/internal/email/noopsender.go b/internal/email/noopsender.go new file mode 100644 index 000000000..82eb8db44 --- /dev/null +++ b/internal/email/noopsender.go @@ -0,0 +1,82 @@ +/* + 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 email + +import ( + "bytes" + "html/template" + + "github.com/sirupsen/logrus" +) + +// NewNoopSender returns a no-op email sender that will just execute the given sendCallback +// every time it would otherwise send an email to the given toAddress with the given message value. +// +// Passing a nil function is also acceptable, in which case the send functions will just return nil. +func NewNoopSender(templateBaseDir string, sendCallback func(toAddress string, message string)) (Sender, error) { + t, err := loadTemplates(templateBaseDir) + if err != nil { + return nil, err + } + + return &noopSender{ + sendCallback: sendCallback, + template: t, + }, nil +} + +type noopSender struct { + sendCallback func(toAddress string, message string) + template *template.Template +} + +func (s *noopSender) SendConfirmEmail(toAddress string, data ConfirmData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil { + return err + } + confirmBody := buf.String() + + msg := assembleMessage(confirmSubject, confirmBody, toAddress, "test@example.org") + + logrus.Tracef("NOT SENDING confirmation email to %s with contents: %s", toAddress, msg) + + if s.sendCallback != nil { + s.sendCallback(toAddress, string(msg)) + } + return nil +} + +func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil { + return err + } + resetBody := buf.String() + + msg := assembleMessage(resetSubject, resetBody, toAddress, "test@example.org") + + logrus.Tracef("NOT SENDING reset email to %s with contents: %s", toAddress, msg) + + if s.sendCallback != nil { + s.sendCallback(toAddress, string(msg)) + } + + return nil +} diff --git a/internal/email/reset.go b/internal/email/reset.go new file mode 100644 index 000000000..786777f1b --- /dev/null +++ b/internal/email/reset.go @@ -0,0 +1,53 @@ +/* + 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 email + +import ( + "bytes" + "net/smtp" +) + +const ( + resetTemplate = "email_reset.tmpl" + resetSubject = "Subject: GoToSocial Password Reset" +) + +func (s *sender) SendResetEmail(toAddress string, data ResetData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil { + return err + } + resetBody := buf.String() + + msg := assembleMessage(resetSubject, resetBody, toAddress, s.from) + return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg) +} + +// ResetData represents data passed into the reset email address template. +type ResetData struct { + // Username to be addressed. + Username string + // URL of the instance to present to the receiver. + InstanceURL string + // Name of the instance to present to the receiver. + InstanceName string + // Link to present to the receiver to click on and begin the reset process. + // Should be a full link with protocol eg., https://example.org/reset_password?token=some-reset-password-token + ResetLink string +} diff --git a/internal/email/sender.go b/internal/email/sender.go new file mode 100644 index 000000000..a6a9abe55 --- /dev/null +++ b/internal/email/sender.go @@ -0,0 +1,60 @@ +/* + 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 email + +import ( + "fmt" + "html/template" + "net/smtp" + + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// Sender contains functions for sending emails to instance users/new signups. +type Sender interface { + // SendConfirmEmail sends a 'please confirm your email' style email to the given toAddress, with the given data. + SendConfirmEmail(toAddress string, data ConfirmData) error + + // SendResetEmail sends a 'reset your password' style email to the given toAddress, with the given data. + SendResetEmail(toAddress string, data ResetData) error +} + +// NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong. +func NewSender(cfg *config.Config) (Sender, error) { + t, err := loadTemplates(cfg.TemplateConfig.BaseDir) + if err != nil { + return nil, err + } + + auth := smtp.PlainAuth("", cfg.SMTPConfig.Username, cfg.SMTPConfig.Password, cfg.SMTPConfig.Host) + + return &sender{ + hostAddress: fmt.Sprintf("%s:%d", cfg.SMTPConfig.Host, cfg.SMTPConfig.Port), + from: cfg.SMTPConfig.From, + auth: auth, + template: t, + }, nil +} + +type sender struct { + hostAddress string + from string + auth smtp.Auth + template *template.Template +} diff --git a/internal/email/util.go b/internal/email/util.go new file mode 100644 index 000000000..1d3db5802 --- /dev/null +++ b/internal/email/util.go @@ -0,0 +1,56 @@ +/* + 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 email + +import ( + "fmt" + "html/template" + "os" + "path/filepath" +) + +const ( + mime = `MIME-version: 1.0; +Content-Type: text/html;` +) + +func loadTemplates(templateBaseDir string) (*template.Template, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("error getting current working directory: %s", err) + } + + // look for all templates that start with 'email_' + tmPath := filepath.Join(cwd, fmt.Sprintf("%semail_*", templateBaseDir)) + return template.ParseGlob(tmPath) +} + +func assembleMessage(mailSubject string, mailBody string, mailTo string, mailFrom string) []byte { + from := fmt.Sprintf("From: GoToSocial <%s>", mailFrom) + to := fmt.Sprintf("To: %s", mailTo) + + msg := []byte( + mailSubject + "\r\n" + + from + "\r\n" + + to + "\r\n" + + mime + "\r\n" + + mailBody + "\r\n") + + return msg +} diff --git a/internal/email/util_test.go b/internal/email/util_test.go new file mode 100644 index 000000000..201bcd92d --- /dev/null +++ b/internal/email/util_test.go @@ -0,0 +1,60 @@ +/* + 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 email_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/email" +) + +type UtilTestSuite struct { + EmailTestSuite +} + +func (suite *UtilTestSuite) 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", + } + + suite.sender.SendConfirmEmail("user@example.org", confirmData) + suite.Len(suite.sentEmails, 1) + suite.Equal("Subject: GoToSocial Email Confirmation\r\nFrom: GoToSocial <test@example.org>\r\nTo: user@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 test!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because you've requested an account on <a href=\"https://example.org\">Test Instance</a>.\n </p>\n <p>\n We just need to confirm that this is your email address. To confirm your email, <a href=\"https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\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=\"https://example.org\">Test Instance</a>.\n </p>\n </div>\n </body>\n</html>\r\n", suite.sentEmails["user@example.org"]) +} + +func (suite *UtilTestSuite) TestTemplateReset() { + resetData := email.ResetData{ + Username: "test", + InstanceURL: "https://example.org", + InstanceName: "Test Instance", + ResetLink: "https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa", + } + + suite.sender.SendResetEmail("user@example.org", resetData) + suite.Len(suite.sentEmails, 1) + suite.Equal("Subject: GoToSocial Password Reset\r\nFrom: GoToSocial <test@example.org>\r\nTo: user@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 test!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because a password reset has been requested for your account on <a href=\"https://example.org\">Test Instance</a>.\n </p>\n <p>\n To reset your password, <a href=\"https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\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=\"https://example.org\">Test Instance</a>.\n </p>\n </div>\n </body>\n</html>\r\n", suite.sentEmails["user@example.org"]) +} + +func TestUtilTestSuite(t *testing.T) { + suite.Run(t, &UtilTestSuite{}) +} 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) } diff --git a/internal/text/common.go b/internal/text/common.go index 1c7d52905..0173b1d01 100644 --- a/internal/text/common.go +++ b/internal/text/common.go @@ -52,7 +52,7 @@ func postformat(in string) string { s = html.UnescapeString(s) // 3. minify html to remove any trailing newlines, spaces, unnecessary elements, etc etc - mini, err := minifyHTML(s) + mini, err := MinifyHTML(s) if err != nil { // if the minify failed, just return what we have return s diff --git a/internal/text/minify.go b/internal/text/minify.go index c6d7b9bc1..1c7c8ce24 100644 --- a/internal/text/minify.go +++ b/internal/text/minify.go @@ -25,8 +25,8 @@ import ( var m *minify.M -// minifyHTML runs html through a minifier, reducing it in size. -func minifyHTML(in string) (string, error) { +// MinifyHTML runs html through a minifier, reducing it in size. +func MinifyHTML(in string) (string, error) { if m == nil { m = minify.New() m.Add("text/html", &html.Minifier{ diff --git a/internal/util/uri.go b/internal/util/uri.go index 5945c7bdd..d1ae1de41 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -54,6 +54,8 @@ const ( UpdatePath = "updates" // BlocksPath is used to generate the URI for a block BlocksPath = "blocks" + // ConfirmEmailPath is used to generate the URI for an email confirmation link + ConfirmEmailPath = "confirm_email" ) // APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains @@ -136,6 +138,12 @@ func GenerateURIForBlock(username string, protocol string, host string, thisBloc return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, BlocksPath, thisBlockID) } +// GenerateURIForEmailConfirm returns a link for email confirmation -- something like: +// https://example.org/confirm_email?token=490e337c-0162-454f-ac48-4b22bb92a205 +func GenerateURIForEmailConfirm(protocol string, host string, token string) string { + return fmt.Sprintf("%s://%s/%s?token=%s", protocol, host, ConfirmEmailPath, token) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs { // The below URLs are used for serving web requests diff --git a/internal/web/base.go b/internal/web/base.go index 0eda1185a..5d19a3f70 100644 --- a/internal/web/base.go +++ b/internal/web/base.go @@ -30,6 +30,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +const ( + confirmEmailPath = "/" + util.ConfirmEmailPath + tokenParam = "token" ) // Module implements the api.ClientModule interface for web pages. @@ -100,6 +106,9 @@ func (m *Module) Route(s router.Router) error { // serve statuses s.AttachHandler(http.MethodGet, "/:user/statuses/:id", m.threadTemplateHandler) + // serve email confirmation page at /confirm_email?token=whatever + s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) + // 404 handler s.AttachNoRouteHandler(m.NotFoundHandler) diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go new file mode 100644 index 000000000..97ed597d3 --- /dev/null +++ b/internal/web/confirmemail.go @@ -0,0 +1,57 @@ +/* + 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 web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func (m *Module) confirmEmailGETHandler(c *gin.Context) { + // if there's no token in the query, just serve the 404 web handler + token := c.Query(tokenParam) + if token == "" { + m.NotFoundHandler(c) + return + } + + ctx := c.Request.Context() + + user, errWithCode := m.processor.UserConfirmEmail(ctx, token) + if errWithCode != nil { + logrus.Debugf("error confirming email: %s", errWithCode.Error()) + // if something goes wrong, just log it and direct to the 404 handler to not give anything away + m.NotFoundHandler(c) + return + } + + instance, err := m.processor.InstanceGet(ctx, m.config.Host) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.HTML(http.StatusOK, "confirmed.tmpl", gin.H{ + "instance": instance, + "email": user.Email, + "username": user.Account.Username, + }) +} |