diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index a85093802..983d49d40 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -129,6 +129,52 @@ func main() { Value: true, EnvVars: []string{envNames.AccountsRequireApproval}, }, + + // MEDIA FLAGS + &cli.IntFlag{ + Name: flagNames.MediaMaxImageSize, + Usage: "Max size of accepted images in bytes", + Value: 1048576, // 1mb + EnvVars: []string{envNames.MediaMaxImageSize}, + }, + &cli.IntFlag{ + Name: flagNames.MediaMaxVideoSize, + Usage: "Max size of accepted videos in bytes", + Value: 5242880, // 5mb + EnvVars: []string{envNames.MediaMaxVideoSize}, + }, + + // STORAGE FLAGS + &cli.StringFlag{ + Name: flagNames.StorageBackend, + Usage: "Storage backend to use for media attachments", + Value: "local", + EnvVars: []string{envNames.StorageBackend}, + }, + &cli.StringFlag{ + Name: flagNames.StorageBasePath, + Usage: "Full path to an already-created directory where gts should store/retrieve media files", + Value: "/opt/gotosocial", + EnvVars: []string{envNames.StorageBasePath}, + }, + &cli.StringFlag{ + Name: flagNames.StorageServeProtocol, + Usage: "Protocol to use for serving media attachments (use https if storage is local)", + Value: "https", + EnvVars: []string{envNames.StorageServeProtocol}, + }, + &cli.StringFlag{ + Name: flagNames.StorageServeHost, + Usage: "Hostname to serve media attachments from (use the same value as host if storage is local)", + Value: "localhost", + EnvVars: []string{envNames.StorageServeHost}, + }, + &cli.StringFlag{ + Name: flagNames.StorageServeBasePath, + Usage: "Path to append to protocol and hostname to create the base path from which media files will be served (default will mostly be fine)", + Value: "/fileserver/media", + EnvVars: []string{envNames.StorageServeBasePath}, + }, }, Commands: []*cli.Command{ { diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go index b747b399b..2426ef750 100644 --- a/internal/apimodule/account/account.go +++ b/internal/apimodule/account/account.go @@ -22,10 +22,10 @@ import ( "net/http" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -62,5 +62,6 @@ func (m *accountModule) Route(r router.Router) error { r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler) r.AttachHandler(http.MethodGet, verifyPath, m.accountVerifyGETHandler) r.AttachHandler(http.MethodPatch, updateCredentialsPath, m.accountUpdateCredentialsPATCHHandler) + r.AttachHandler(http.MethodGet, basePathWithID, m.accountGETHandler) return nil } diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go index 23cb530e0..44b28bd52 100644 --- a/internal/apimodule/account/accountcreate.go +++ b/internal/apimodule/account/accountcreate.go @@ -119,7 +119,7 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco return errors.New("registration is not open for this server") } - if err := util.ValidateSignUpUsername(form.Username); err != nil { + if err := util.ValidateUsername(form.Username); err != nil { return err } @@ -127,7 +127,7 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco return err } - if err := util.ValidateSignUpPassword(form.Password); err != nil { + if err := util.ValidateNewPassword(form.Password); err != nil { return err } diff --git a/internal/apimodule/account/account_test.go b/internal/apimodule/account/accountcreate_test.go similarity index 93% rename from internal/apimodule/account/account_test.go rename to internal/apimodule/account/accountcreate_test.go index 293f5512d..95f73deb6 100644 --- a/internal/apimodule/account/account_test.go +++ b/internal/apimodule/account/accountcreate_test.go @@ -52,7 +52,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -type AccountTestSuite struct { +type AccountCreateTestSuite struct { suite.Suite config *config.Config log *logrus.Logger @@ -74,7 +74,7 @@ type AccountTestSuite struct { */ // SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountTestSuite) SetupSuite() { +func (suite *AccountCreateTestSuite) SetupSuite() { // some of our subsequent entities need a log so create this here log := logrus.New() log.SetLevel(logrus.TraceLevel) @@ -109,6 +109,8 @@ func (suite *AccountTestSuite) SetupSuite() { // Direct config to local postgres instance c := config.Empty() + c.Protocol = "http" + c.Host = "localhost" c.DBConfig = &config.DBConfig{ Type: "postgres", Address: "localhost", @@ -121,6 +123,13 @@ func (suite *AccountTestSuite) SetupSuite() { c.MediaConfig = &config.MediaConfig{ MaxImageSize: 2 << 20, } + c.StorageConfig = &config.StorageConfig{ + Backend: "local", + BasePath: "/tmp", + ServeProtocol: "http", + ServeHost: "localhost", + ServeBasePath: "/fileserver/media", + } suite.config = c // use an actual database for this, because it's just easier than mocking one out @@ -155,14 +164,14 @@ func (suite *AccountTestSuite) SetupSuite() { suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) } -func (suite *AccountTestSuite) TearDownSuite() { +func (suite *AccountCreateTestSuite) TearDownSuite() { if err := suite.db.Stop(context.Background()); err != nil { logrus.Panicf("error closing db connection: %s", err) } } // SetupTest creates a db connection and creates necessary tables before each test -func (suite *AccountTestSuite) SetupTest() { +func (suite *AccountCreateTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ &model.User{}, @@ -199,7 +208,7 @@ func (suite *AccountTestSuite) SetupTest() { } // TearDownTest drops tables to make sure there's no data in the db -func (suite *AccountTestSuite) TearDownTest() { +func (suite *AccountCreateTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ @@ -231,7 +240,7 @@ func (suite *AccountTestSuite) TearDownTest() { // and at the end of it a new user and account should be added into the database. // // This is the handler served at /api/v1/accounts as POST -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerSuccessful() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // setup recorder := httptest.NewRecorder() @@ -307,7 +316,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: // only registered applications can create accounts, and we don't provide one here. -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerNoAuth() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { // setup recorder := httptest.NewRecorder() @@ -330,7 +339,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerNoAuth() { } // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerNoForm() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { // setup recorder := httptest.NewRecorder() @@ -352,7 +361,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerNoForm() { } // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { // setup recorder := httptest.NewRecorder() @@ -377,7 +386,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { } // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { // setup recorder := httptest.NewRecorder() @@ -402,7 +411,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { } // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { // setup recorder := httptest.NewRecorder() @@ -428,7 +437,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() } // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { // setup recorder := httptest.NewRecorder() @@ -455,7 +464,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { } // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required -func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { // setup recorder := httptest.NewRecorder() @@ -485,7 +494,7 @@ func (suite *AccountTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() TESTING: AccountUpdateCredentialsPATCHHandler */ -func (suite *AccountTestSuite) TestAccountUpdateCredentialsPATCHHandler() { +func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { // put test local account in db err := suite.db.Put(suite.testAccountLocal) @@ -533,6 +542,6 @@ func (suite *AccountTestSuite) TestAccountUpdateCredentialsPATCHHandler() { // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) } -func TestAccountTestSuite(t *testing.T) { - suite.Run(t, new(AccountTestSuite)) +func TestAccountCreateTestSuite(t *testing.T) { + suite.Run(t, new(AccountCreateTestSuite)) } diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go new file mode 100644 index 000000000..ffbc5e36c --- /dev/null +++ b/internal/apimodule/account/accountget.go @@ -0,0 +1,58 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/model" +) + +// accountGetHandler serves the account information held by the server in response to a GET +// request. It should be served as a GET at /api/v1/accounts/:id. +// +// See: https://docs.joinmastodon.org/methods/accounts/ +func (m *accountModule) accountGETHandler(c *gin.Context) { + targetAcctID := c.Param(idKey) + if targetAcctID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error":"no account id specified"}) + return + } + + targetAccount := &model.Account{} + if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + c.JSON(http.StatusNotFound, gin.H{"error":"Record not found"}) + return + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + acctInfo, err := m.db.AccountToMastoPublic(targetAccount) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, acctInfo) +} diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go index 6221dac77..17dcb01f1 100644 --- a/internal/apimodule/account/accountupdate.go +++ b/internal/apimodule/account/accountupdate.go @@ -29,6 +29,7 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/db/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" ) @@ -38,6 +39,8 @@ import ( // TODO: this can be optimized massively by building up a picture of what we want the new account // details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one // which is not gonna make the database very happy when lots of requests are going through. +// This way it would also be safer because the update won't happen until *all* the fields are validated. +// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") authed, err := oauth.MustAuth(c, true, false, false, true) @@ -80,6 +83,10 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.DisplayName != nil { + if err := util.ValidateDisplayName(*form.DisplayName); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &model.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -87,6 +94,10 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Note != nil { + if err := util.ValidateNote(*form.Note); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &model.Account{}); err != nil { l.Debugf("error updating note: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -122,11 +133,11 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Source != nil { - + // TODO: parse source nicely and update } if form.FieldsAttributes != nil { - + // TODO: parse fields attributes nicely and update } // fetch the account with all updated values set @@ -159,7 +170,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { // the account's new avatar image. func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { var err error - if avatar.Size > m.config.MediaConfig.MaxImageSize { + if int(avatar.Size) > m.config.MediaConfig.MaxImageSize { err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize) return nil, err } @@ -192,7 +203,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun // the account's new header image. func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { var err error - if header.Size > m.config.MediaConfig.MaxImageSize { + if int(header.Size) > m.config.MediaConfig.MaxImageSize { err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize) return nil, err } diff --git a/internal/apimodule/account/accountupdate_test.go b/internal/apimodule/account/accountupdate_test.go new file mode 100644 index 000000000..646f8e56a --- /dev/null +++ b/internal/apimodule/account/accountupdate_test.go @@ -0,0 +1,301 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package account + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/oauth2/v4" + "github.com/superseriousbusiness/oauth2/v4/models" + oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" +) + +type AccountUpdateTestSuite struct { + suite.Suite + config *config.Config + log *logrus.Logger + testAccountLocal *model.Account + testAccountRemote *model.Account + testUser *model.User + testApplication *model.Application + testToken oauth2.TokenInfo + mockOauthServer *oauth.MockServer + mockStorage *storage.MockStorage + mediaHandler media.MediaHandler + db db.DB + accountModule *accountModule + newUserFormHappyPath url.Values +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *AccountUpdateTestSuite) SetupSuite() { + // some of our subsequent entities need a log so create this here + log := logrus.New() + log.SetLevel(logrus.TraceLevel) + suite.log = log + + suite.testAccountLocal = &model.Account{ + ID: uuid.NewString(), + Username: "test_user", + } + + // can use this test application throughout + suite.testApplication = &model.Application{ + ID: "weeweeeeeeeeeeeeee", + Name: "a test application", + Website: "https://some-application-website.com", + RedirectURI: "http://localhost:8080", + ClientID: "a-known-client-id", + ClientSecret: "some-secret", + Scopes: "read", + VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa", + } + + // can use this test token throughout + suite.testToken = &oauthmodels.Token{ + ClientID: "a-known-client-id", + RedirectURI: "http://localhost:8080", + Scope: "read", + Code: "123456789", + CodeCreateAt: time.Now(), + CodeExpiresIn: time.Duration(10 * time.Minute), + } + + // Direct config to local postgres instance + c := config.Empty() + c.Protocol = "http" + c.Host = "localhost" + c.DBConfig = &config.DBConfig{ + Type: "postgres", + Address: "localhost", + Port: 5432, + User: "postgres", + Password: "postgres", + Database: "postgres", + ApplicationName: "gotosocial", + } + c.MediaConfig = &config.MediaConfig{ + MaxImageSize: 2 << 20, + } + c.StorageConfig = &config.StorageConfig{ + Backend: "local", + BasePath: "/tmp", + ServeProtocol: "http", + ServeHost: "localhost", + ServeBasePath: "/fileserver/media", + } + suite.config = c + + // use an actual database for this, because it's just easier than mocking one out + database, err := db.New(context.Background(), c, log) + if err != nil { + suite.FailNow(err.Error()) + } + suite.db = database + + // we need to mock the oauth server because account creation needs it to create a new token + suite.mockOauthServer = &oauth.MockServer{} + suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { + l := suite.log.WithField("func", "GenerateUserAccessToken") + token := args.Get(0).(oauth2.TokenInfo) + l.Infof("received token %+v", token) + clientSecret := args.Get(1).(string) + l.Infof("received clientSecret %+v", clientSecret) + userID := args.Get(2).(string) + l.Infof("received userID %+v", userID) + }).Return(&models.Token{ + Code: "we're authorized now!", + }, nil) + + suite.mockStorage = &storage.MockStorage{} + // We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage + suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) + + // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) + suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + + // and finally here's the thing we're actually testing! + suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) +} + +func (suite *AccountUpdateTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +// SetupTest creates a db connection and creates necessary tables before each test +func (suite *AccountUpdateTestSuite) SetupTest() { + // create all the tables we might need in thie suite + models := []interface{}{ + &model.User{}, + &model.Account{}, + &model.Follow{}, + &model.FollowRequest{}, + &model.Status{}, + &model.Application{}, + &model.EmailDomainBlock{}, + &model.MediaAttachment{}, + } + for _, m := range models { + if err := suite.db.CreateTable(m); err != nil { + logrus.Panicf("db connection error: %s", err) + } + } + + // form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test + suite.newUserFormHappyPath = url.Values{ + "reason": []string{"a very good reason that's at least 40 characters i swear"}, + "username": []string{"test_user"}, + "email": []string{"user@example.org"}, + "password": []string{"very-strong-password"}, + "agreement": []string{"true"}, + "locale": []string{"en"}, + } + + // same with accounts config + suite.config.AccountsConfig = &config.AccountsConfig{ + OpenRegistration: true, + RequireApproval: true, + ReasonRequired: true, + } +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *AccountUpdateTestSuite) TearDownTest() { + + // remove all the tables we might have used so it's clear for the next test + models := []interface{}{ + &model.User{}, + &model.Account{}, + &model.Follow{}, + &model.FollowRequest{}, + &model.Status{}, + &model.Application{}, + &model.EmailDomainBlock{}, + &model.MediaAttachment{}, + } + for _, m := range models { + if err := suite.db.DropTable(m); err != nil { + logrus.Panicf("error dropping table: %s", err) + } + } +} + +/* + ACTUAL TESTS +*/ + +/* + TESTING: AccountUpdateCredentialsPATCHHandler +*/ + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + + // put test local account in db + err := suite.db.Put(suite.testAccountLocal) + assert.NoError(suite.T(), err) + + // attach avatar to request form + avatarFile, err := os.Open("../../media/test/test-jpeg.jpg") + assert.NoError(suite.T(), err) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") + assert.NoError(suite.T(), err) + + _, err = io.Copy(avatarPart, avatarFile) + assert.NoError(suite.T(), err) + + err = avatarFile.Close() + assert.NoError(suite.T(), err) + + // set display name to a new value + displayNamePart, err := writer.CreateFormField("display_name") + assert.NoError(suite.T(), err) + + _, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah")) + assert.NoError(suite.T(), err) + + // set locked to true + lockedPart, err := writer.CreateFormField("locked") + assert.NoError(suite.T(), err) + + _, err = io.Copy(lockedPart, bytes.NewBufferString("true")) + assert.NoError(suite.T(), err) + + // close the request writer, the form is now prepared + err = writer.Close() + assert.NoError(suite.T(), err) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) + ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) + ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", updateCredentialsPath), body) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + suite.accountModule.accountUpdateCredentialsPATCHHandler(ctx) + + // check response + + // 1. we should have OK because our request was valid + suite.EqualValues(http.StatusOK, recorder.Code) + + // 2. we should have an error message in the result body + result := recorder.Result() + defer result.Body.Close() + // TODO: implement proper checks here + // + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +} + +func TestAccountUpdateTestSuite(t *testing.T) { + suite.Run(t, new(AccountUpdateTestSuite)) +} diff --git a/internal/apimodule/account/accountverify_test.go b/internal/apimodule/account/accountverify_test.go new file mode 100644 index 000000000..223a0c145 --- /dev/null +++ b/internal/apimodule/account/accountverify_test.go @@ -0,0 +1,19 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package account diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go new file mode 100644 index 000000000..af7bc5a24 --- /dev/null +++ b/internal/apimodule/fileserver/fileserver.go @@ -0,0 +1,42 @@ +package fileserver + +import ( + "fmt" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/router" + "github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// fileServer implements the RESTAPIModule interface. +// The goal here is to serve requested media files if the gotosocial server is configured to use local storage. +type fileServer struct { + config *config.Config + db db.DB + storage storage.Storage + log *logrus.Logger + storageBase string +} + +// New returns a new fileServer module +func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { + + storageBase := fmt.Sprintf("%s", config.StorageConfig.BasePath) // TODO: do this properly + + return &fileServer{ + config: config, + db: db, + storage: storage, + log: log, + storageBase: storageBase, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *fileServer) Route(s router.Router) error { + // s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index f68c6e683..dc38fdc3a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type Config struct { TemplateConfig *TemplateConfig `yaml:"template"` AccountsConfig *AccountsConfig `yaml:"accounts"` MediaConfig *MediaConfig `yaml:"media"` + StorageConfig *StorageConfig `yaml:"storage"` } // FromFile returns a new config from a file, or an error if something goes amiss. @@ -62,6 +63,9 @@ func Empty() *Config { return &Config{ DBConfig: &DBConfig{}, TemplateConfig: &TemplateConfig{}, + AccountsConfig: &AccountsConfig{}, + MediaConfig: &MediaConfig{}, + StorageConfig: &StorageConfig{}, } } @@ -147,6 +151,36 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { if f.IsSet(fn.AccountsRequireApproval) { c.AccountsConfig.RequireApproval = f.Bool(fn.AccountsRequireApproval) } + + // media flags + if c.MediaConfig.MaxImageSize == 0 || f.IsSet(fn.MediaMaxImageSize) { + c.MediaConfig.MaxImageSize = f.Int(fn.MediaMaxImageSize) + } + + if c.MediaConfig.MaxVideoSize == 0 || f.IsSet(fn.MediaMaxVideoSize) { + c.MediaConfig.MaxVideoSize = f.Int(fn.MediaMaxVideoSize) + } + + // storage flags + if c.StorageConfig.Backend == "" || f.IsSet(fn.StorageBackend) { + c.StorageConfig.Backend = f.String(fn.StorageBackend) + } + + if c.StorageConfig.BasePath == "" || f.IsSet(fn.StorageBasePath) { + c.StorageConfig.BasePath = f.String(fn.StorageBasePath) + } + + if c.StorageConfig.ServeProtocol == "" || f.IsSet(fn.StorageServeProtocol) { + c.StorageConfig.ServeProtocol = f.String(fn.StorageServeProtocol) + } + + if c.StorageConfig.ServeHost == "" || f.IsSet(fn.StorageServeHost) { + c.StorageConfig.ServeHost = f.String(fn.StorageServeHost) + } + + if c.StorageConfig.ServeBasePath == "" || f.IsSet(fn.StorageServeBasePath) { + c.StorageConfig.ServeBasePath = f.String(fn.StorageServeBasePath) + } } // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. @@ -166,15 +200,27 @@ type Flags struct { ConfigPath string Host string Protocol string + DbType string DbAddress string DbPort string DbUser string DbPassword string DbDatabase string + TemplateBaseDir string + AccountsOpenRegistration string AccountsRequireApproval string + + MediaMaxImageSize string + MediaMaxVideoSize string + + StorageBackend string + StorageBasePath string + StorageServeProtocol string + StorageServeHost string + StorageServeBasePath string } // GetFlagNames returns a struct containing the names of the various flags used for @@ -186,15 +232,27 @@ func GetFlagNames() Flags { ConfigPath: "config-path", Host: "host", Protocol: "protocol", + DbType: "db-type", DbAddress: "db-address", DbPort: "db-port", DbUser: "db-user", DbPassword: "db-password", DbDatabase: "db-database", + TemplateBaseDir: "template-basedir", + AccountsOpenRegistration: "accounts-open-registration", AccountsRequireApproval: "accounts-require-approval", + + MediaMaxImageSize: "media-max-image-size", + MediaMaxVideoSize: "media-max-video-size", + + StorageBackend: "storage-backend", + StorageBasePath: "storage-base-path", + StorageServeProtocol: "storage-serve-protocol", + StorageServeHost: "storage-serve-host", + StorageServeBasePath: "storage-serve-base-path", } } @@ -207,14 +265,26 @@ func GetEnvNames() Flags { ConfigPath: "GTS_CONFIG_PATH", Host: "GTS_HOST", Protocol: "GTS_PROTOCOL", + DbType: "GTS_DB_TYPE", DbAddress: "GTS_DB_ADDRESS", DbPort: "GTS_DB_PORT", DbUser: "GTS_DB_USER", DbPassword: "GTS_DB_PASSWORD", DbDatabase: "GTS_DB_DATABASE", + TemplateBaseDir: "GTS_TEMPLATE_BASEDIR", + AccountsOpenRegistration: "GTS_ACCOUNTS_OPEN_REGISTRATION", AccountsRequireApproval: "GTS_ACCOUNTS_REQUIRE_APPROVAL", + + MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE", + MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE", + + StorageBackend: "GTS_STORAGE_BACKEND", + StorageBasePath: "GTS_STORAGE_BASE_PATH", + StorageServeProtocol: "GTS_STORAGE_SERVE_PROTOCOL", + StorageServeHost: "GTS_STORAGE_SERVE_HOST", + StorageServeBasePath: "GTS_STORAGE_SERVE_BASE_PATH", } } diff --git a/internal/config/media.go b/internal/config/media.go index ae209d71f..816e236b2 100644 --- a/internal/config/media.go +++ b/internal/config/media.go @@ -18,7 +18,10 @@ package config -// AccountsConfig contains configuration to do with creating accounts, new registrations, and defaults. +// MediaConfig contains configuration for receiving and parsing media files and attachments type MediaConfig struct { - MaxImageSize int64 `yaml:"maxImageSize"` + // Max size of uploaded images in bytes + MaxImageSize int `yaml:"maxImageSize"` + // Max size of uploaded video in bytes + MaxVideoSize int `yaml:"maxVideoSize"` } diff --git a/internal/config/storage.go b/internal/config/storage.go new file mode 100644 index 000000000..4a8ff79e4 --- /dev/null +++ b/internal/config/storage.go @@ -0,0 +1,36 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package config + +// StorageConfig contains configuration for storage and serving of media files and attachments +type StorageConfig struct { + // Type of storage backend to use: currently only 'local' is supported. + // TODO: add S3 support here. + Backend string `yaml:"backend"` + + // The base path for storing things. Should be an already-existing directory. + BasePath string `yaml:"basePath"` + + // Protocol to use when *serving* media files from storage + ServeProtocol string `yaml:"serveProtocol"` + // Host to use when *serving* media files from storage + ServeHost string `yaml:"serveHost"` + // Base path to use when *serving* media files from storage + ServeBasePath string `yaml:"serveBasePath"` +} diff --git a/internal/db/db.go b/internal/db/db.go index 2cd9c1562..4921270e7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -177,6 +177,11 @@ type DB interface { // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, // so serve it only to an authorized user who should have permission to see it. AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) + + // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. + // In other words, this is the public record that the server has of an account. + AccountToMastoPublic(account *model.Account) (*mastotypes.Account, error) } // New returns a new database service that satisfies the DB interface and, by extension, diff --git a/internal/db/model/mediaattachment.go b/internal/db/model/mediaattachment.go index 4cb90f51c..3aff18d80 100644 --- a/internal/db/model/mediaattachment.go +++ b/internal/db/model/mediaattachment.go @@ -116,7 +116,7 @@ const ( // FileMeta describes metadata about the actual contents of the file. type FileMeta struct { Original Original - Small Small + Small Small } // Small implements SmallMeta and can be used for a thumbnail of any media type diff --git a/internal/db/pg.go b/internal/db/pg.go index 8d6c4a763..0eef582fd 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -43,7 +43,7 @@ import ( // postgresService satisfies the DB interface type postgresService struct { - config *config.DBConfig + config *config.Config conn *pg.DB log *logrus.Entry cancel context.CancelFunc @@ -106,7 +106,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry // we can confidently return this useable postgres service now return &postgresService{ - config: c.DBConfig, + config: c, conn: conn, log: log, cancel: cancel, @@ -240,7 +240,7 @@ func (ps *postgresService) GetByID(id string, i interface{}) error { } func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error { - if err := ps.conn.Model(i).Where(fmt.Sprintf("%s = ?", key), value).Select(); err != nil { + if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} } @@ -275,7 +275,7 @@ func (ps *postgresService) UpdateByID(id string, i interface{}) error { } func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { - _, err := ps.conn.Model(i).Set("? = ?", key, value).Where("id = ?", id).Update() + _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update() return err } @@ -290,7 +290,7 @@ func (ps *postgresService) DeleteByID(id string, i interface{}) error { } func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error { - if _, err := ps.conn.Model(i).Where(fmt.Sprintf("%s = ?", key), value).Delete(); err != nil { + if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} } @@ -437,10 +437,14 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr return nil, err } + // should be something like https://example.org/@some_username + url := fmt.Sprintf("%s://%s/@%s", ps.config.Protocol, ps.config.Host, username) + a := &model.Account{ Username: username, DisplayName: username, Reason: reason, + URL: url, PrivateKey: key, PublicKey: &key.PublicKey, ActorType: "Person", @@ -460,6 +464,7 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr Locale: locale, UnconfirmedEmail: email, CreatedByApplicationID: appID, + Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user } if _, err = ps.conn.Model(u).Insert(); err != nil { return nil, err @@ -614,10 +619,10 @@ func (ps *postgresService) AccountToMastoSensitive(a *model.Account) (*mastotype Bot: a.Bot, CreatedAt: a.CreatedAt.Format(time.RFC3339), Note: a.Note, - URL: a.URL, // TODO: set this during account creation - Avatar: aviURL, // TODO: build this url properly using host and protocol from config - AvatarStatic: aviURLStatic, // TODO: build this url properly using host and protocol from config - Header: headerURL, // TODO: build this url properly using host and protocol from config + URL: a.URL, + Avatar: aviURL, // TODO: build this url properly using host and protocol from config + AvatarStatic: aviURLStatic, // TODO: build this url properly using host and protocol from config + Header: headerURL, // TODO: build this url properly using host and protocol from config HeaderStatic: headerURLStatic, // TODO: build this url properly using host and protocol from config FollowersCount: followersCount, FollowingCount: followingCount, @@ -628,3 +633,7 @@ func (ps *postgresService) AccountToMastoSensitive(a *model.Account) (*mastotype Fields: fields, }, nil } + +func (ps *postgresService) AccountToMastoPublic(account *model.Account) (*mastotypes.Account, error) { + return nil, nil +} diff --git a/internal/media/media.go b/internal/media/media.go index f66a215ef..fd517e2cd 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -151,13 +151,16 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it extension := strings.Split(contentType, "/")[1] newMediaID := uuid.NewString() + + base := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath, ) + // we store the original... - originalPath := fmt.Sprintf("%s/media/%s/original/%s.%s", accountID, headerOrAvi, newMediaID, extension) + originalPath := fmt.Sprintf("%s/%s/%s/original/%s.%s", base, accountID, headerOrAvi, newMediaID, extension) if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } // and a thumbnail... - smallPath := fmt.Sprintf("%s/media/%s/small/%s.%s", accountID, headerOrAvi, newMediaID, extension) + smallPath := fmt.Sprintf("%s/%s/%s/small/%s.%s", base, accountID, headerOrAvi, newMediaID, extension) if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } diff --git a/internal/media/media_test.go b/internal/media/media_test.go index b073e7d33..18855a22e 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -55,6 +55,8 @@ func (suite *MediaTestSuite) SetupSuite() { // Direct config to local postgres instance c := config.Empty() + c.Protocol = "http" + c.Host = "localhost" c.DBConfig = &config.DBConfig{ Type: "postgres", Address: "localhost", @@ -67,6 +69,13 @@ func (suite *MediaTestSuite) SetupSuite() { c.MediaConfig = &config.MediaConfig{ MaxImageSize: 2 << 20, } + c.StorageConfig = &config.StorageConfig{ + Backend: "local", + BasePath: "/tmp", + ServeProtocol: "http", + ServeHost: "localhost", + ServeBasePath: "/fileserver/media", + } suite.config = c // use an actual database for this, because it's just easier than mocking one out database, err := db.New(context.Background(), c, log) diff --git a/internal/util/validation.go b/internal/util/validation.go index f032539cc..8a35c8150 100644 --- a/internal/util/validation.go +++ b/internal/util/validation.go @@ -50,8 +50,8 @@ var ( NewUsernameRegex = regexp.MustCompile(NewUsernameRegexString) ) -// ValidateSignUpPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. -func ValidateSignUpPassword(password string) error { +// ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. +func ValidateNewPassword(password string) error { if password == "" { return errors.New("no password provided") } @@ -63,9 +63,9 @@ func ValidateSignUpPassword(password string) error { return pwv.Validate(password, MinimumPasswordEntropy) } -// ValidateSignUpUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length). +// ValidateUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length). // Returns an error if not. -func ValidateSignUpUsername(username string) error { +func ValidateUsername(username string) error { if username == "" { return errors.New("no username provided") } @@ -127,3 +127,13 @@ func ValidateSignUpReason(reason string, reasonRequired bool) error { } return nil } + +func ValidateDisplayName(displayName string) error { + // TODO: add some validation logic here -- length, characters, etc + return nil +} + +func ValidateNote(note string) error { + // TODO: add some validation logic here -- length, characters, etc + return nil +} diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go index 4c40c88b3..28d6457a6 100644 --- a/internal/util/validation_test.go +++ b/internal/util/validation_test.go @@ -42,42 +42,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() { strongPassword := "3dX5@Zc%mV*W2MBNEy$@" var err error - err = ValidateSignUpPassword(empty) + err = ValidateNewPassword(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no password provided"), err) } - err = ValidateSignUpPassword(terriblePassword) + err = ValidateNewPassword(terriblePassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"), err) } - err = ValidateSignUpPassword(weakPassword) + err = ValidateNewPassword(weakPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using numbers or using a longer password"), err) } - err = ValidateSignUpPassword(shortPassword) + err = ValidateNewPassword(shortPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err) } - err = ValidateSignUpPassword(specialPassword) + err = ValidateNewPassword(specialPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err) } - err = ValidateSignUpPassword(longPassword) + err = ValidateNewPassword(longPassword) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateSignUpPassword(tooLong) + err = ValidateNewPassword(tooLong) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("password should be no more than 64 chars"), err) } - err = ValidateSignUpPassword(strongPassword) + err = ValidateNewPassword(strongPassword) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } @@ -94,42 +94,42 @@ func (suite *ValidationTestSuite) TestValidateUsername() { goodUsername := "this_is_a_good_username" var err error - err = ValidateSignUpUsername(empty) + err = ValidateUsername(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no username provided"), err) } - err = ValidateSignUpUsername(tooLong) + err = ValidateUsername(tooLong) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("username should be no more than 64 chars but '%s' was 66", tooLong), err) } - err = ValidateSignUpUsername(withSpaces) + err = ValidateUsername(withSpaces) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", withSpaces), err) } - err = ValidateSignUpUsername(weirdChars) + err = ValidateUsername(weirdChars) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", weirdChars), err) } - err = ValidateSignUpUsername(leadingSpace) + err = ValidateUsername(leadingSpace) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", leadingSpace), err) } - err = ValidateSignUpUsername(trailingSpace) + err = ValidateUsername(trailingSpace) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", trailingSpace), err) } - err = ValidateSignUpUsername(newlines) + err = ValidateUsername(newlines) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", newlines), err) } - err = ValidateSignUpUsername(goodUsername) + err = ValidateUsername(goodUsername) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) }