gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 2aaec827321ec711b98e13335899cf750f270105
parent de1f90ee4671d3a5272a261330f57b2b9d24b749
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date:   Sun, 31 Oct 2021 15:46:23 +0100

smtp + email confirmation (#285)

* add smtp configuration

* add email confirm + reset templates

* add email sender to testrig

* flesh out the email sender interface

* go fmt

* golint

* update from field with more clarity

* tidy up the email formatting

* fix tests

* add email sender to processor

* tidy client api processing a bit

* further tidying in fromClientAPI

* pin new account to user

* send msg to processor on new account creation

* generate confirm email uri

* remove emailer from account processor again

* add processCreateAccountFromClientAPI

* move emailer accountprocessor => userprocessor

* add email sender to user processor

* SendConfirmEmail function

* add noop email sender

* use noop email sender in tests

* only assemble message if callback is not nil

* use noop email sender if no smtp host is defined

* minify email html before sending

* fix wrong email address

* email confirm test

* fmt

* serve web hndler

* add email confirm handler

* init test log properly on testrig

* log emails that *would* have been sent

* go fmt ./...

* unexport confirm email handler

* updatedAt

* test confirm email function

* don't allow tokens older than 7 days

* change error message a bit

* add basic smtp docs

* add a few more snippets

* typo

* add email sender to outbox tests

* don't use dutch wikipedia link

* don't minify email html
Diffstat:
Mcmd/gotosocial/flags.go | 1+
Acmd/gotosocial/smtpflags.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/configuration/smtp.md | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexample/config.yaml | 32++++++++++++++++++++++++++++++++
Minternal/api/client/account/account_test.go | 19++++++++++++-------
Minternal/api/client/fileserver/servefile_test.go | 6+++++-
Minternal/api/client/followrequest/followrequest_test.go | 15+++++++++------
Minternal/api/client/media/mediacreate_test.go | 5++++-
Minternal/api/client/status/status_test.go | 45++++++++++++++++++++++++++++++++++++++-------
Minternal/api/client/status/statusboost_test.go | 28----------------------------
Minternal/api/client/status/statuscreate_test.go | 28----------------------------
Minternal/api/client/status/statusfave_test.go | 28----------------------------
Minternal/api/client/status/statusfavedby_test.go | 28----------------------------
Minternal/api/client/status/statusget_test.go | 29-----------------------------
Minternal/api/client/status/statusunfave_test.go | 28----------------------------
Minternal/api/client/user/user_test.go | 20+++++++++++++-------
Minternal/api/s2s/user/inboxpost_test.go | 12++++++++----
Minternal/api/s2s/user/outboxget_test.go | 9++++++---
Minternal/api/s2s/user/repliesget_test.go | 9++++++---
Minternal/api/s2s/user/user_test.go | 5++++-
Minternal/api/s2s/user/userget_test.go | 3++-
Minternal/api/s2s/webfinger/webfinger_test.go | 5++++-
Minternal/api/s2s/webfinger/webfingerget_test.go | 4++--
Minternal/cliactions/server/server.go | 29++++++++++++++++++++++++-----
Minternal/cliactions/testrig/testrig.go | 6+++++-
Minternal/config/config.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/default.go | 26++++++++++++++++++++++++++
Ainternal/config/smtp.go | 33+++++++++++++++++++++++++++++++++
Minternal/db/bundb/admin.go | 4+++-
Ainternal/email/confirm.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/email/email.go | 20--------------------
Ainternal/email/email_test.go | 39+++++++++++++++++++++++++++++++++++++++
Ainternal/email/noopsender.go | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/email/reset.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/email/sender.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/email/util.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/email/util_test.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/account/account_test.go | 5+++++
Minternal/processing/account/create.go | 20++++++++++++++++++++
Minternal/processing/fromclientapi.go | 400+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Minternal/processing/processor.go | 19++++++++++++++++---
Minternal/processing/processor_test.go | 5++++-
Minternal/processing/user.go | 5+++++
Ainternal/processing/user/emailconfirm.go | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/user/emailconfirm_test.go | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/user/user.go | 17++++++++++++-----
Minternal/processing/user/user_test.go | 13++++++++++---
Minternal/text/common.go | 2+-
Minternal/text/minify.go | 4++--
Minternal/util/uri.go | 8++++++++
Minternal/web/base.go | 9+++++++++
Ainternal/web/confirmemail.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/email.go | 42++++++++++++++++++++++++++++++++++++++++++
Mtestrig/processor.go | 5+++--
Aweb/template/confirmed.tmpl | 10++++++++++
Aweb/template/email_confirm.tmpl | 30++++++++++++++++++++++++++++++
Aweb/template/email_reset.tmpl | 30++++++++++++++++++++++++++++++
57 files changed, 1564 insertions(+), 416 deletions(-)

diff --git 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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package config + +// SMTPConfig holds configuration for sending emails using the smtp protocol. +type SMTPConfig struct { + // Host of the smtp server. + Host string `yaml:"host"` + // Port of the smtp server. + Port int `yaml:"port"` + // Username to use when authenticating with the smtp server. + Username string `yaml:"username"` + // Password to use when authenticating with the smtp server. + Password string `yaml:"password"` + // From address to use when sending emails. + From string `yaml:"from"` +} diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go @@ -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 := &gtsmodel.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 @@ -0,0 +1,53 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email + +import ( + "bytes" + "net/smtp" +) + +const ( + confirmTemplate = "email_confirm.tmpl" + confirmSubject = "Subject: GoToSocial Email Confirmation" +) + +func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil { + return err + } + confirmBody := buf.String() + + msg := assembleMessage(confirmSubject, confirmBody, toAddress, s.from) + return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg) +} + +// ConfirmData represents data passed into the confirm email address template. +type ConfirmData struct { + // Username to be addressed. + Username string + // URL of the instance to present to the receiver. + InstanceURL string + // Name of the instance to present to the receiver. + InstanceName string + // Link to present to the receiver to click on and do the confirmation. + // Should be a full link with protocol eg., https://example.org/confirm_email?token=some-long-token + ConfirmLink string +} diff --git a/internal/email/email.go b/internal/email/email.go @@ -1,20 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -// Package email provides a service for interacting with an SMTP server -package email diff --git a/internal/email/email_test.go b/internal/email/email_test.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email_test + +import ( + "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 @@ -0,0 +1,82 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email + +import ( + "bytes" + "html/template" + + "github.com/sirupsen/logrus" +) + +// NewNoopSender returns a no-op email sender that will just execute the given sendCallback +// every time it would otherwise send an email to the given toAddress with the given message value. +// +// Passing a nil function is also acceptable, in which case the send functions will just return nil. +func NewNoopSender(templateBaseDir string, sendCallback func(toAddress string, message string)) (Sender, error) { + t, err := loadTemplates(templateBaseDir) + if err != nil { + return nil, err + } + + return &noopSender{ + sendCallback: sendCallback, + template: t, + }, nil +} + +type noopSender struct { + sendCallback func(toAddress string, message string) + template *template.Template +} + +func (s *noopSender) SendConfirmEmail(toAddress string, data ConfirmData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil { + return err + } + confirmBody := buf.String() + + msg := assembleMessage(confirmSubject, confirmBody, toAddress, "test@example.org") + + logrus.Tracef("NOT SENDING confirmation email to %s with contents: %s", toAddress, msg) + + if s.sendCallback != nil { + s.sendCallback(toAddress, string(msg)) + } + return nil +} + +func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil { + return err + } + resetBody := buf.String() + + msg := assembleMessage(resetSubject, resetBody, toAddress, "test@example.org") + + logrus.Tracef("NOT SENDING reset email to %s with contents: %s", toAddress, msg) + + if s.sendCallback != nil { + s.sendCallback(toAddress, string(msg)) + } + + return nil +} diff --git a/internal/email/reset.go b/internal/email/reset.go @@ -0,0 +1,53 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email + +import ( + "bytes" + "net/smtp" +) + +const ( + resetTemplate = "email_reset.tmpl" + resetSubject = "Subject: GoToSocial Password Reset" +) + +func (s *sender) SendResetEmail(toAddress string, data ResetData) error { + buf := &bytes.Buffer{} + if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil { + return err + } + resetBody := buf.String() + + msg := assembleMessage(resetSubject, resetBody, toAddress, s.from) + return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg) +} + +// ResetData represents data passed into the reset email address template. +type ResetData struct { + // Username to be addressed. + Username string + // URL of the instance to present to the receiver. + InstanceURL string + // Name of the instance to present to the receiver. + InstanceName string + // Link to present to the receiver to click on and begin the reset process. + // Should be a full link with protocol eg., https://example.org/reset_password?token=some-reset-password-token + ResetLink string +} diff --git a/internal/email/sender.go b/internal/email/sender.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email + +import ( + "fmt" + "html/template" + "net/smtp" + + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// Sender contains functions for sending emails to instance users/new signups. +type Sender interface { + // SendConfirmEmail sends a 'please confirm your email' style email to the given toAddress, with the given data. + SendConfirmEmail(toAddress string, data ConfirmData) error + + // SendResetEmail sends a 'reset your password' style email to the given toAddress, with the given data. + SendResetEmail(toAddress string, data ResetData) error +} + +// NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong. +func NewSender(cfg *config.Config) (Sender, error) { + t, err := loadTemplates(cfg.TemplateConfig.BaseDir) + if err != nil { + return nil, err + } + + auth := smtp.PlainAuth("", cfg.SMTPConfig.Username, cfg.SMTPConfig.Password, cfg.SMTPConfig.Host) + + return &sender{ + hostAddress: fmt.Sprintf("%s:%d", cfg.SMTPConfig.Host, cfg.SMTPConfig.Port), + from: cfg.SMTPConfig.From, + auth: auth, + template: t, + }, nil +} + +type sender struct { + hostAddress string + from string + auth smtp.Auth + template *template.Template +} diff --git a/internal/email/util.go b/internal/email/util.go @@ -0,0 +1,56 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email + +import ( + "fmt" + "html/template" + "os" + "path/filepath" +) + +const ( + mime = `MIME-version: 1.0; +Content-Type: text/html;` +) + +func loadTemplates(templateBaseDir string) (*template.Template, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("error getting current working directory: %s", err) + } + + // look for all templates that start with 'email_' + tmPath := filepath.Join(cwd, fmt.Sprintf("%semail_*", templateBaseDir)) + return template.ParseGlob(tmPath) +} + +func assembleMessage(mailSubject string, mailBody string, mailTo string, mailFrom string) []byte { + from := fmt.Sprintf("From: GoToSocial <%s>", mailFrom) + to := fmt.Sprintf("To: %s", mailTo) + + msg := []byte( + mailSubject + "\r\n" + + from + "\r\n" + + to + "\r\n" + + mime + "\r\n" + + mailBody + "\r\n") + + return msg +} diff --git a/internal/email/util_test.go b/internal/email/util_test.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package email_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/email" +) + +type UtilTestSuite struct { + EmailTestSuite +} + +func (suite *UtilTestSuite) TestTemplateConfirm() { + confirmData := email.ConfirmData{ + Username: "test", + InstanceURL: "https://example.org", + InstanceName: "Test Instance", + ConfirmLink: "https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa", + } + + suite.sender.SendConfirmEmail("user@example.org", confirmData) + suite.Len(suite.sentEmails, 1) + suite.Equal("Subject: GoToSocial Email Confirmation\r\nFrom: GoToSocial <test@example.org>\r\nTo: user@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n<!DOCTYPE html>\n<html>\n </head>\n <body>\n <div>\n <h1>\n Hello test!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because you've requested an account on <a href=\"https://example.org\">Test Instance</a>.\n </p>\n <p>\n We just need to confirm that this is your email address. To confirm your email, <a href=\"https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\n </code>\n </p>\n </div>\n <div>\n <p>\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href=\"https://example.org\">Test Instance</a>.\n </p>\n </div>\n </body>\n</html>\r\n", suite.sentEmails["user@example.org"]) +} + +func (suite *UtilTestSuite) TestTemplateReset() { + resetData := email.ResetData{ + Username: "test", + InstanceURL: "https://example.org", + InstanceName: "Test Instance", + ResetLink: "https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa", + } + + suite.sender.SendResetEmail("user@example.org", resetData) + suite.Len(suite.sentEmails, 1) + suite.Equal("Subject: GoToSocial Password Reset\r\nFrom: GoToSocial <test@example.org>\r\nTo: user@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n<!DOCTYPE html>\n<html>\n </head>\n <body>\n <div>\n <h1>\n Hello test!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because a password reset has been requested for your account on <a href=\"https://example.org\">Test Instance</a>.\n </p>\n <p>\n To reset your password, <a href=\"https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\n </code>\n </p>\n </div>\n <div>\n <p>\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href=\"https://example.org\">Test Instance</a>.\n </p>\n </div>\n </body>\n</html>\r\n", suite.sentEmails["user@example.org"]) +} + +func TestUtilTestSuite(t *testing.T) { + suite.Run(t, &UtilTestSuite{}) +} diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go @@ -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 @@ -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 @@ -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, &gtsmodel.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 := &gtsmodel.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, &gtsmodel.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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,132 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +var ( + oneWeek = 168 * time.Hour +) + +func (p *processor) SendConfirmEmail(ctx context.Context, user *gtsmodel.User, username string) error { + if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { + // user has already confirmed this email address, so there's nothing to do + return nil + } + + // We need a token and a link for the user to click on. + // We'll use a uuid as our token since it's basically impossible to guess. + // From the uuid package we use (which uses crypto/rand under the hood): + // Randomly generated UUIDs have 122 random bits. One's annual risk of being + // hit by a meteorite is estimated to be one chance in 17 billion, that + // means the probability is about 0.00000000006 (6 × 10−11), + // equivalent to the odds of creating a few tens of trillions of UUIDs in a + // year and having one duplicate. + confirmationToken := uuid.NewString() + confirmationLink := util.GenerateURIForEmailConfirm(p.config.Protocol, p.config.Host, confirmationToken) + + // pull our instance entry from the database so we can greet the user nicely in the email + instance := &gtsmodel.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 := &gtsmodel.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 @@ -0,0 +1,114 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type EmailConfirmTestSuite struct { + UserStandardTestSuite +} + +func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() { + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought) + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Time{} + user.ConfirmationToken = "" + + err := suite.user.SendConfirmEmail(context.Background(), user, "the_mighty_zork") + suite.NoError(err) + + // zork should have an email now + suite.Len(suite.sentEmails, 1) + email, ok := suite.sentEmails["some.email@example.org"] + suite.True(ok) + + // a token should be set on zork + token := user.ConfirmationToken + suite.NotEmpty(token) + + // email should contain the token + emailShould := fmt.Sprintf("Subject: GoToSocial Email Confirmation\r\nFrom: GoToSocial <test@example.org>\r\nTo: some.email@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n<!DOCTYPE html>\n<html>\n </head>\n <body>\n <div>\n <h1>\n Hello the_mighty_zork!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because you've requested an account on <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n <p>\n We just need to confirm that this is your email address. To confirm your email, <a href=\"http://localhost:8080/confirm_email?token=%s\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n http://localhost:8080/confirm_email?token=%s\n </code>\n </p>\n </div>\n <div>\n <p>\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n </div>\n </body>\n</html>\r\n", token, token) + suite.Equal(emailShould, email) + + // confirmationSentAt should be recent + suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmail() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 5 minutes ago + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-5 * time.Minute) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByPrimaryKey(ctx, user) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.NoError(errWithCode) + + // email should now be confirmed and token cleared + suite.Equal("some.email@example.org", updatedUser.Email) + suite.Empty(updatedUser.UnconfirmedEmail) + suite.Empty(updatedUser.ConfirmationToken) + suite.WithinDuration(updatedUser.ConfirmedAt, time.Now(), 1*time.Minute) + suite.WithinDuration(updatedUser.UpdatedAt, time.Now(), 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 8 days ago + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-192 * time.Hour) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByPrimaryKey(ctx, user) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.Nil(updatedUser) + suite.EqualError(errWithCode, "ConfirmEmail: confirmation token expired") +} + +func TestEmailConfirmTestSuite(t *testing.T) { + suite.Run(t, &EmailConfirmTestSuite{}) +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,57 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func (m *Module) confirmEmailGETHandler(c *gin.Context) { + // if there's no token in the query, just serve the 404 web handler + token := c.Query(tokenParam) + if token == "" { + m.NotFoundHandler(c) + return + } + + ctx := c.Request.Context() + + user, errWithCode := m.processor.UserConfirmEmail(ctx, token) + if errWithCode != nil { + logrus.Debugf("error confirming email: %s", errWithCode.Error()) + // if something goes wrong, just log it and direct to the 404 handler to not give anything away + m.NotFoundHandler(c) + return + } + + instance, err := m.processor.InstanceGet(ctx, m.config.Host) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.HTML(http.StatusOK, "confirmed.tmpl", gin.H{ + "instance": instance, + "email": user.Email, + "username": user.Account.Username, + }) +} diff --git a/testrig/email.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 @@ -0,0 +1,9 @@ +{{ template "header.tmpl" .}} +<main> + <section> + <h1>Email Address Confirmed</h1> + <p>Thanks {{.username}}! Your email address <b>{{.email}}</b> has been confirmed.<p> + </section> +</main> + +{{ template "footer.tmpl" .}} +\ No newline at end of file diff --git a/web/template/email_confirm.tmpl b/web/template/email_confirm.tmpl @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + </head> + <body> + <div> + <h1> + Hello {{.Username}}! + </h1> + </div> + <div> + <p> + You are receiving this mail because you've requested an account on <a href="{{.InstanceURL}}">{{.InstanceName}}</a>. + </p> + <p> + We just need to confirm that this is your email address. To confirm your email, <a href="{{.ConfirmLink}}">click here</a> or paste the following in your browser's address bar: + </p> + <p> + <code> + {{.ConfirmLink}} + </code> + </p> + </div> + <div> + <p> + If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href="{{.InstanceURL}}">{{.InstanceName}}</a>. + </p> + </div> + </body> +</html> +\ No newline at end of file diff --git a/web/template/email_reset.tmpl b/web/template/email_reset.tmpl @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + </head> + <body> + <div> + <h1> + Hello {{.Username}}! + </h1> + </div> + <div> + <p> + You are receiving this mail because a password reset has been requested for your account on <a href="{{.InstanceURL}}">{{.InstanceName}}</a>. + </p> + <p> + To reset your password, <a href="{{.ResetLink}}">click here</a> or paste the following in your browser's address bar: + </p> + <p> + <code> + {{.ResetLink}} + </code> + </p> + </div> + <div> + <p> + If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href="{{.InstanceURL}}">{{.InstanceName}}</a>. + </p> + </div> + </body> +</html> +\ No newline at end of file