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
This commit is contained in:
tobi 2021-10-31 15:46:23 +01:00 committed by GitHub
parent de1f90ee46
commit 2aaec82732
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1543 additions and 398 deletions

View file

@ -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...)

View file

@ -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},
},
}
}

View file

@ -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`.

View file

@ -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: ""

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)
}

View file

@ -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"]

View file

@ -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

View file

@ -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() {

View file

@ -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)

View file

@ -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))
}

View file

@ -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() {

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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",
}
}

View file

@ -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",
}
}

33
internal/config/smtp.go Normal file
View file

@ -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"`
}

View file

@ -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,

53
internal/email/confirm.go Normal file
View file

@ -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
}

View file

@ -16,5 +16,24 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Package email provides a service for interacting with an SMTP server
package email
package email_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type EmailTestSuite struct {
suite.Suite
sender email.Sender
sentEmails map[string]string
}
func (suite *EmailTestSuite) SetupTest() {
testrig.InitTestLog()
suite.sentEmails = make(map[string]string)
suite.sender = testrig.NewEmailSender("../../web/template/", suite.sentEmails)
}

View file

@ -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
}

53
internal/email/reset.go Normal file
View file

@ -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
}

60
internal/email/sender.go Normal file
View file

@ -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
}

56
internal/email/util.go Normal file
View file

@ -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
}

View file

@ -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{})
}

View file

@ -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")

View file

@ -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",

View file

@ -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 {

View file

@ -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{

View file

@ -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")

View file

@ -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)
}

View file

@ -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 × 1011),
// 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
}

View file

@ -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{})
}

View file

@ -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,
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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{

View file

@ -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

View file

@ -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)

View file

@ -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,
})
}

42
testrig/email.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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" .}}

View file

@ -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>

View file

@ -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>