diff --git a/cmd/gotosocial/flags.go b/cmd/gotosocial/flags.go index 355ec0b81..8e162f6c7 100644 --- a/cmd/gotosocial/flags.go +++ b/cmd/gotosocial/flags.go @@ -39,6 +39,7 @@ func getFlags() []cli.Flag { statusesFlags(flagNames, envNames, defaults), letsEncryptFlags(flagNames, envNames, defaults), oidcFlags(flagNames, envNames, defaults), + smtpFlags(flagNames, envNames, defaults), } for _, fs := range flagSets { flags = append(flags, fs...) diff --git a/cmd/gotosocial/smtpflags.go b/cmd/gotosocial/smtpflags.go new file mode 100644 index 000000000..5c790ef7e --- /dev/null +++ b/cmd/gotosocial/smtpflags.go @@ -0,0 +1,59 @@ +/* + 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 . +*/ + +package main + +import ( + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/urfave/cli/v2" +) + +func smtpFlags(flagNames, envNames config.Flags, defaults config.Defaults) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: flagNames.SMTPHost, + Usage: "Host of the smtp server. Eg., 'smtp.eu.mailgun.org'", + Value: defaults.SMTPHost, + EnvVars: []string{envNames.SMTPHost}, + }, + &cli.IntFlag{ + Name: flagNames.SMTPPort, + Usage: "Port of the smtp server. Eg., 587", + Value: defaults.SMTPPort, + EnvVars: []string{envNames.SMTPPort}, + }, + &cli.StringFlag{ + Name: flagNames.SMTPUsername, + Usage: "Username to authenticate with the smtp server as. Eg., 'postmaster@mail.example.org'", + Value: defaults.SMTPUsername, + EnvVars: []string{envNames.SMTPUsername}, + }, + &cli.StringFlag{ + Name: flagNames.SMTPPassword, + Usage: "Password to pass to the smtp server.", + Value: defaults.SMTPPassword, + EnvVars: []string{envNames.SMTPPassword}, + }, + &cli.StringFlag{ + Name: flagNames.SMTPFrom, + Usage: "Address to use as the 'from' field of the email. Eg., 'gotosocial@example.org'", + Value: defaults.SMTPFrom, + EnvVars: []string{envNames.SMTPFrom}, + }, + } +} diff --git a/docs/configuration/smtp.md b/docs/configuration/smtp.md new file mode 100644 index 000000000..d191be3c8 --- /dev/null +++ b/docs/configuration/smtp.md @@ -0,0 +1,67 @@ +# Email Config (smtp) + +GoToSocial supports sending emails to users via the [Simple Mail Transfer Protocol](https://wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) or **smtp**. + +Configuring GoToSocial to send emails is **not required** in order to have a properly running instance. Still, it's very useful for doing things like sending confirmation emails and notifications, and handling password reset requests. + +In order to make GoToSocial email sending work, you need an smtp-compatible mail service running somewhere, either as a server on the same machine that GoToSocial is running on, or via an external service like [Mailgun](https://mailgun.com). It may also be possible to use a free personal email address for sending emails, if your email provider supports smtp (check with them--most do), but you might run into trouble sending lots of emails. + +## Settings + +The configuration options for smtp are as follows: + +```yaml +####################### +##### SMTP CONFIG ##### +####################### + +# Config for sending emails via an smtp server. See https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol +smtp: + + # String. The hostname of the smtp server you want to use. + # If this is not set, smtp will not be used to send emails, and you can ignore the other settings. + # Examples: ["mail.example.org", "localhost"] + # Default: "" + host: "" + # Int. Port to use to connect to the smtp server. + # Examples: [] + # Default: 0 + port: 0 + # String. Username to use when authenticating with the smtp server. + # This should have been provided to you by your smtp host. + # This is often, but not always, an email address. + # Examples: ["maillord@example.org"] + # Default: "" + username: + # String. Password to use when authenticating with the smtp server. + # This should have been provided to you by your smtp host. + # Examples: ["1234", "password"] + # Default: "" + password: + # String. 'From' address for sent emails. + # Examples: ["mail@example.org"] + # Default: "" + from: "" +``` + +Note that if you don't set `Host`, then email sending via smtp will be disabled, and the other settings will be ignored. GoToSocial will still log (at trace level) emails that *would* have been sent if smtp was enabled. + +## Behavior + +### SSL + +GoToSocial requires your smtp server to present valid SSL certificates. Most of the big services like Mailgun do this anyway, but if you're running your own mail server without SSL for some reason, and you're trying to connect GoToSocial to it, it will not work. + +The exception to this requirement is if you're running your mail server (or bridge to a mail server) on `localhost`, in which case SSL certs are not required. + +### When are emails sent? + +Currently, emails are only sent to users to request email confirmation when a new account is created, or to serve password reset requests. More email functionality will probably be added later. + +### HTML versus Plaintext + +Emails are sent in HTML by default. At this point, there is no option to send emails in plaintext, but this is something that might be added later if there's enough demand for it. + +## Customization + +If you like, you can customize the templates that are used for generating emails. Follow the examples in `web/templates`. diff --git a/example/config.yaml b/example/config.yaml index 50b863725..51472418f 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -360,3 +360,35 @@ oidc: - "email" - "profile" - "groups" + +####################### +##### SMTP CONFIG ##### +####################### + +# Config for sending emails via an smtp server. See https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol +smtp: + + # String. The hostname of the smtp server you want to use. + # If this is not set, smtp will not be used to send emails, and you can ignore the other settings. + # Examples: ["mail.example.org", "localhost"] + # Default: "" + host: "" + # Int. Port to use to connect to the smtp server. + # Examples: [] + # Default: 0 + port: 0 + # String. Username to use when authenticating with the smtp server. + # This should have been provided to you by your smtp host. + # This is often, but not always, an email address. + # Examples: ["maillord@example.org"] + # Default: "" + username: + # String. Password to use when authenticating with the smtp server. + # This should have been provided to you by your smtp host. + # Examples: ["1234", "password"] + # Default: "" + password: + # String. 'From' address for sent emails. + # Examples: ["mail@example.org"] + # Default: "" + from: "" 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 . +*/ + +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 . +*/ + +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 similarity index 61% rename from internal/email/email.go rename to 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 . */ -// 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 . +*/ + +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 . +*/ + +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 . +*/ + +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 . +*/ + +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 . +*/ + +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 \r\nTo: user@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n\n\n \n \n
\n

\n Hello test!\n

\n
\n
\n

\n You are receiving this mail because you've requested an account on Test Instance.\n

\n

\n We just need to confirm that this is your email address. To confirm your email, click here or paste the following in your browser's address bar:\n

\n

\n \n https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\n \n

\n
\n
\n

\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of Test Instance.\n

\n
\n \n\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 \r\nTo: user@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n\n\n \n \n
\n

\n Hello test!\n

\n
\n
\n

\n You are receiving this mail because a password reset has been requested for your account on Test Instance.\n

\n

\n To reset your password, click here or paste the following in your browser's address bar:\n

\n

\n \n https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\n \n

\n
\n
\n

\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of Test Instance.\n

\n
\n \n\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 . +*/ + +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 . +*/ + +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 \r\nTo: some.email@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n\n\n \n \n
\n

\n Hello the_mighty_zork!\n

\n
\n
\n

\n You are receiving this mail because you've requested an account on localhost:8080.\n

\n

\n We just need to confirm that this is your email address. To confirm your email, click here or paste the following in your browser's address bar:\n

\n

\n \n http://localhost:8080/confirm_email?token=%s\n \n

\n
\n
\n

\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of localhost:8080.\n

\n
\n \n\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 . +*/ + +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, + }) +} diff --git a/testrig/email.go b/testrig/email.go new file mode 100644 index 000000000..60ee96ac2 --- /dev/null +++ b/testrig/email.go @@ -0,0 +1,42 @@ +/* + 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 . +*/ + +package testrig + +import "github.com/superseriousbusiness/gotosocial/internal/email" + +// NewEmailSender returns a noop email sender that won't make any remote calls. +// +// If sentEmails is not nil, the noop callback function will place sent emails in +// the map, with email address of the recipient as the key, and the value as the +// parsed email message as it would have been sent. +func NewEmailSender(templateBaseDir string, sentEmails map[string]string) email.Sender { + var sendCallback func(toAddress string, message string) + + if sentEmails != nil { + sendCallback = func(toAddress string, message string) { + sentEmails[toAddress] = message + } + } + + s, err := email.NewNoopSender(templateBaseDir, sendCallback) + if err != nil { + panic(err) + } + return s +} diff --git a/testrig/processor.go b/testrig/processor.go index 88ded9ca7..fa91250b9 100644 --- a/testrig/processor.go +++ b/testrig/processor.go @@ -21,11 +21,12 @@ package testrig import ( "git.iim.gay/grufwub/go-store/kv" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/processing" ) // NewTestProcessor returns a Processor suitable for testing purposes -func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator) processing.Processor { - return processing.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, NewTestTimelineManager(db), db) +func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator, emailSender email.Sender) processing.Processor { + return processing.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, NewTestTimelineManager(db), db, emailSender) } diff --git a/web/template/confirmed.tmpl b/web/template/confirmed.tmpl new file mode 100644 index 000000000..920f7d3b2 --- /dev/null +++ b/web/template/confirmed.tmpl @@ -0,0 +1,9 @@ +{{ template "header.tmpl" .}} +
+
+

Email Address Confirmed

+

Thanks {{.username}}! Your email address {{.email}} has been confirmed.

+

+
+ +{{ template "footer.tmpl" .}} \ No newline at end of file diff --git a/web/template/email_confirm.tmpl b/web/template/email_confirm.tmpl new file mode 100644 index 000000000..0a9907921 --- /dev/null +++ b/web/template/email_confirm.tmpl @@ -0,0 +1,29 @@ + + + + +
+

+ Hello {{.Username}}! +

+
+
+

+ You are receiving this mail because you've requested an account on {{.InstanceName}}. +

+

+ We just need to confirm that this is your email address. To confirm your email, click here or paste the following in your browser's address bar: +

+

+ + {{.ConfirmLink}} + +

+
+
+

+ If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceName}}. +

+
+ + \ No newline at end of file diff --git a/web/template/email_reset.tmpl b/web/template/email_reset.tmpl new file mode 100644 index 000000000..7318c6a45 --- /dev/null +++ b/web/template/email_reset.tmpl @@ -0,0 +1,29 @@ + + + + +
+

+ Hello {{.Username}}! +

+
+
+

+ You are receiving this mail because a password reset has been requested for your account on {{.InstanceName}}. +

+

+ To reset your password, click here or paste the following in your browser's address bar: +

+

+ + {{.ResetLink}} + +

+
+
+

+ If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceName}}. +

+
+ + \ No newline at end of file