diff options
Diffstat (limited to 'internal/apimodule')
41 files changed, 0 insertions, 6077 deletions
diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go deleted file mode 100644 index a836afcdb..000000000 --- a/internal/apimodule/account/account.go +++ /dev/null @@ -1,117 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // IDKey is the key to use for retrieving account ID in requests - IDKey = "id" - // BasePath is the base API path for this module - BasePath = "/api/v1/accounts" - // BasePathWithID is the base path for this module with the ID key - BasePathWithID = BasePath + "/:" + IDKey - // VerifyPath is for verifying account credentials - VerifyPath = BasePath + "/verify_credentials" - // UpdateCredentialsPath is for updating account credentials - UpdateCredentialsPath = BasePath + "/update_credentials" -) - -// Module implements the ClientAPIModule interface for account-related actions -type Module struct { - config *config.Config - db db.DB - oauthServer oauth.Server - mediaHandler media.Handler - mastoConverter mastotypes.Converter - log *logrus.Logger -} - -// New returns a new account module -func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - config: config, - db: db, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) - r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) - r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) - return nil -} - -// CreateTables creates the required tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} - -func (m *Module) muxHandler(c *gin.Context) { - ru := c.Request.RequestURI - switch c.Request.Method { - case http.MethodGet: - if strings.HasPrefix(ru, VerifyPath) { - m.AccountVerifyGETHandler(c) - } else { - m.AccountGETHandler(c) - } - case http.MethodPatch: - if strings.HasPrefix(ru, UpdateCredentialsPath) { - m.AccountUpdateCredentialsPATCHHandler(c) - } - } -} diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go deleted file mode 100644 index fb21925b8..000000000 --- a/internal/apimodule/account/accountcreate.go +++ /dev/null @@ -1,155 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "errors" - "fmt" - "net" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/oauth2/v4" -) - -// AccountCreatePOSTHandler handles create account requests, validates them, -// and puts them in the database if they're valid. -// It should be served as a POST at /api/v1/accounts -func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "accountCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, false, false) - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - l.Trace("parsing request form") - form := &mastotypes.AccountCreateRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) - return - } - - l.Tracef("validating form %+v", form) - if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - clientIP := c.ClientIP() - l.Tracef("attempting to parse client ip address %s", clientIP) - signUpIP := net.ParseIP(clientIP) - if signUpIP == nil { - l.Debugf("error validating sign up ip address %s", clientIP) - c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) - return - } - - ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application) - if err != nil { - l.Errorf("internal server error while creating new account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, ti) -} - -// accountCreate does the dirty work of making an account and user in the database. -// It then returns a token to the caller, for use with the new account, as per the -// spec here: https://docs.joinmastodon.org/methods/accounts/ -func (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) { - l := m.log.WithField("func", "accountCreate") - - // don't store a reason if we don't require one - reason := form.Reason - if !m.config.AccountsConfig.ReasonRequired { - reason = "" - } - - l.Trace("creating new username and account") - user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID) - if err != nil { - return nil, fmt.Errorf("error creating new signup in the database: %s", err) - } - - l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID) - accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID) - if err != nil { - return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) - } - - return &mastotypes.Token{ - AccessToken: accessToken.GetAccess(), - TokenType: "Bearer", - Scope: accessToken.GetScope(), - CreatedAt: accessToken.GetAccessCreateAt().Unix(), - }, nil -} - -// validateCreateAccount checks through all the necessary prerequisites for creating a new account, -// according to the provided account create request. If the account isn't eligible, an error will be returned. -func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error { - if !c.OpenRegistration { - return errors.New("registration is not open for this server") - } - - if err := util.ValidateUsername(form.Username); err != nil { - return err - } - - if err := util.ValidateEmail(form.Email); err != nil { - return err - } - - if err := util.ValidateNewPassword(form.Password); err != nil { - return err - } - - if !form.Agreement { - return errors.New("agreement to terms and conditions not given") - } - - if err := util.ValidateLanguage(form.Locale); err != nil { - return err - } - - if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil { - return err - } - - if err := database.IsEmailAvailable(form.Email); err != nil { - return err - } - - if err := database.IsUsernameAvailable(form.Username); err != nil { - return err - } - - return nil -} diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go deleted file mode 100644 index 5003be139..000000000 --- a/internal/apimodule/account/accountget.go +++ /dev/null @@ -1,57 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -) - -// 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 *Module) AccountGETHandler(c *gin.Context) { - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) - return - } - - targetAccount := >smodel.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 - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acctInfo, err := m.mastoConverter.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 deleted file mode 100644 index 7709697bf..000000000 --- a/internal/apimodule/account/accountupdate.go +++ /dev/null @@ -1,260 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "bytes" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. -// It should be served as a PATCH at /api/v1/accounts/update_credentials -// -// 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 *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { - l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") - authed, err := oauth.MustAuth(c, true, false, false, true) - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - l.Tracef("retrieved account %+v", authed.Account.ID) - - l.Trace("parsing request form") - form := &mastotypes.UpdateCredentialsRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // if everything on the form is nil, then nothing has been set and we shouldn't continue - if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { - l.Debugf("could not parse form from request") - c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) - return - } - - if form.Discoverable != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil { - l.Debugf("error updating discoverable: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Bot != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil { - l.Debugf("error updating bot: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - 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, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - 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, >smodel.Account{}); err != nil { - l.Debugf("error updating note: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Avatar != nil && form.Avatar.Size != 0 { - avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID) - if err != nil { - l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) - } - - if form.Header != nil && form.Header.Size != 0 { - headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID) - if err != nil { - l.Debugf("could not update header for account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) - } - - if form.Locked != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source != nil { - if form.Source.Language != nil { - if err := util.ValidateLanguage(*form.Source.Language); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source.Sensitive != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source.Privacy != nil { - if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - } - - // if form.FieldsAttributes != nil { - // // TODO: parse fields attributes nicely and update - // } - - // fetch the account with all updated values set - updatedAccount := >smodel.Account{} - if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { - l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount) - if err != nil { - l.Tracef("could not convert account into mastosensitive account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) - c.JSON(http.StatusOK, acctSensitive) -} - -/* - HELPER FUNCTIONS -*/ - -// TODO: try to combine the below two functions because this is a lot of code repetition. - -// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new avatar image. -func (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - 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 - } - f, err := avatar.Open() - if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided avatar: size 0 bytes") - } - - // do the setting - avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar) - if err != nil { - return nil, fmt.Errorf("error processing avatar: %s", err) - } - - return avatarInfo, f.Close() -} - -// UpdateAccountHeader does the dirty work of checking the header part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new header image. -func (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - 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 - } - f, err := header.Open() - if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided header: size 0 bytes") - } - - // do the setting - headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader) - if err != nil { - return nil, fmt.Errorf("error processing header: %s", err) - } - - return headerInfo, f.Close() -} diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go deleted file mode 100644 index 9edf1e73a..000000000 --- a/internal/apimodule/account/accountverify.go +++ /dev/null @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountVerifyGETHandler serves a user's account details to them IF they reached this -// handler while in possession of a valid token, according to the oauth middleware. -// It should be served as a GET at /api/v1/accounts/verify_credentials -func (m *Module) AccountVerifyGETHandler(c *gin.Context) { - l := m.log.WithField("func", "accountVerifyGETHandler") - authed, err := oauth.MustAuth(c, true, false, false, true) - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) - acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account) - if err != nil { - l.Tracef("could not convert account into mastosensitive account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) - c.JSON(http.StatusOK, acctSensitive) -} diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go deleted file mode 100644 index 81eab467a..000000000 --- a/internal/apimodule/account/test/accountcreate_test.go +++ /dev/null @@ -1,551 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "bytes" - "context" - "encoding/json" - "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/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - - "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" - "golang.org/x/crypto/bcrypt" -) - -type AccountCreateTestSuite struct { - suite.Suite - config *config.Config - log *logrus.Logger - testAccountLocal *gtsmodel.Account - testApplication *gtsmodel.Application - testToken oauth2.TokenInfo - mockOauthServer *oauth.MockServer - mockStorage *storage.MockStorage - mediaHandler media.Handler - mastoConverter mastotypes.Converter - db db.DB - accountModule *account.Module - newUserFormHappyPath url.Values -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountCreateTestSuite) 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 = >smodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = >smodel.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{ - Access: "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) - - suite.mastoConverter = mastotypes.New(suite.config, suite.db) - - // and finally here's the thing we're actually testing! - suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -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 *AccountCreateTestSuite) SetupTest() { - // create all the tables we might need in thie suite - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.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 *AccountCreateTestSuite) TearDownTest() { - - // remove all the tables we might have used so it's clear for the next test - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - for _, m := range models { - if err := suite.db.DropTable(m); err != nil { - logrus.Panicf("error dropping table: %s", err) - } - } -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: AccountCreatePOSTHandler -*/ - -// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, -// 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 *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - - // 1. we should have OK from our call to the function - suite.EqualValues(http.StatusOK, recorder.Code) - - // 2. we should have a token in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - t := &mastomodel.Token{} - err = json.Unmarshal(b, t) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) - - // check new account - - // 1. we should be able to get the new account from the db - acct := >smodel.Account{} - err = suite.db.GetWhere("username", "test_user", acct) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), acct) - // 2. reason should be set - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) - // 3. display name should be equal to username by default - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) - // 4. domain should be nil because this is a local account - assert.Nil(suite.T(), nil, acct.Domain) - // 5. id should be set and parseable as a uuid - assert.NotNil(suite.T(), acct.ID) - _, err = uuid.Parse(acct.ID) - assert.Nil(suite.T(), err) - // 6. private and public key should be set - assert.NotNil(suite.T(), acct.PrivateKey) - assert.NotNil(suite.T(), acct.PublicKey) - - // check new user - - // 1. we should be able to get the new user from the db - usr := >smodel.User{} - err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) - assert.Nil(suite.T(), err) - assert.NotNil(suite.T(), usr) - - // 2. user should have account id set to account we got above - assert.Equal(suite.T(), acct.ID, usr.AccountID) - - // 3. id should be set and parseable as a uuid - assert.NotNil(suite.T(), usr.ID) - _, err = uuid.Parse(usr.ID) - assert.Nil(suite.T(), err) - - // 4. locale should be equal to what we requested - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) - - // 5. created by application id should be equal to the app id - assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) - - // 6. password should be matcheable to what we set above - err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) - assert.Nil(suite.T(), err) -} - -// 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 *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - - // 1. we should have forbidden from our call to the function because we didn't auth - suite.EqualValues(http.StatusForbidden, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - // set a weak password - ctx.Request.Form.Set("password", "weak") - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - // set an invalid locale - ctx.Request.Form.Set("locale", "neverneverland") - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // close registrations - suite.config.AccountsConfig.OpenRegistration = false - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // remove reason - ctx.Request.Form.Set("reason", "") - - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // remove reason - ctx.Request.Form.Set("reason", "just cuz") - - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) -} - -/* - TESTING: AccountUpdateCredentialsPATCHHandler -*/ - -func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - - // put test local account in db - err := suite.db.Put(suite.testAccountLocal) - assert.NoError(suite.T(), err) - - // attach avatar to request - aviFile, err := os.Open("../../media/test/test-jpeg.jpg") - assert.NoError(suite.T(), err) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") - assert.NoError(suite.T(), err) - - _, err = io.Copy(part, aviFile) - assert.NoError(suite.T(), err) - - err = aviFile.Close() - assert.NoError(suite.T(), err) - - 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", account.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 TestAccountCreateTestSuite(t *testing.T) { - suite.Run(t, new(AccountCreateTestSuite)) -} diff --git a/internal/apimodule/account/test/accountupdate_test.go b/internal/apimodule/account/test/accountupdate_test.go deleted file mode 100644 index 1c6f528a1..000000000 --- a/internal/apimodule/account/test/accountupdate_test.go +++ /dev/null @@ -1,303 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "bytes" - "context" - "fmt" - "io" - "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/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "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 *gtsmodel.Account - testApplication *gtsmodel.Application - testToken oauth2.TokenInfo - mockOauthServer *oauth.MockServer - mockStorage *storage.MockStorage - mediaHandler media.Handler - mastoConverter mastotypes.Converter - db db.DB - accountModule *account.Module - 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 = >smodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = >smodel.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) - - suite.mastoConverter = mastotypes.New(suite.config, suite.db) - - // and finally here's the thing we're actually testing! - suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -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{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.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{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.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", account.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/test/accountverify_test.go b/internal/apimodule/account/test/accountverify_test.go deleted file mode 100644 index 223a0c145..000000000 --- a/internal/apimodule/account/test/accountverify_test.go +++ /dev/null @@ -1,19 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go deleted file mode 100644 index 2ebe9c7a7..000000000 --- a/internal/apimodule/admin/admin.go +++ /dev/null @@ -1,88 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package admin - -import ( - "fmt" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // BasePath is the base API path for this module - BasePath = "/api/v1/admin" - // EmojiPath is used for posting/deleting custom emojis - EmojiPath = BasePath + "/custom_emojis" -) - -// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc) -type Module struct { - config *config.Config - db db.DB - mediaHandler media.Handler - mastoConverter mastotypes.Converter - log *logrus.Logger -} - -// New returns a new admin module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler) - return nil -} - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - >smodel.Emoji{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/admin/emojicreate.go b/internal/apimodule/admin/emojicreate.go deleted file mode 100644 index 49e5492dd..000000000 --- a/internal/apimodule/admin/emojicreate.go +++ /dev/null @@ -1,130 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package admin - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "emojiCreatePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account - authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) - return - } - - // extract the media create form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) - form := &mastotypes.EmojiCreateRequest{} - if err := c.ShouldBind(form); err != nil { - l.Debugf("error parsing form %+v: %s", c.Request.Form, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) - return - } - - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) - if err := validateCreateEmoji(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // open the emoji and extract the bytes from it - f, err := form.Image.Open() - if err != nil { - l.Debugf("error opening emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)}) - return - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - l.Debugf("error reading emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)}) - return - } - if size == 0 { - l.Debug("could not read provided emoji: size 0 bytes") - c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"}) - return - } - - // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using - emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) - if err != nil { - l.Debugf("error reading emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)}) - return - } - - mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji) - if err != nil { - l.Debugf("error converting emoji to mastotype: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)}) - return - } - - if err := m.db.Put(emoji); err != nil { - l.Debugf("database error while processing emoji: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)}) - return - } - - c.JSON(http.StatusOK, mastoEmoji) -} - -func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error { - // check there actually is an image attached and it's not size 0 - if form.Image == nil || form.Image.Size == 0 { - return errors.New("no emoji given") - } - - // a very superficial check to see if the media size limit is exceeded - if form.Image.Size > media.EmojiMaxBytes { - return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) - } - - return util.ValidateEmojiShortcode(form.Shortcode) -} diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go deleted file mode 100644 index 6d7dbdb83..000000000 --- a/internal/apimodule/apimodule.go +++ /dev/null @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface. -package apimodule - -import ( - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set -// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;) -// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ -type ClientAPIModule interface { - Route(s router.Router) error - CreateTables(db db.DB) error -} diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go deleted file mode 100644 index 518192758..000000000 --- a/internal/apimodule/app/app.go +++ /dev/null @@ -1,77 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package app - -import ( - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// BasePath is the base path for this api module -const BasePath = "/api/v1/apps" - -// Module implements the ClientAPIModule interface for requests relating to registering/removing applications -type Module struct { - server oauth.Server - db db.DB - mastoConverter mastotypes.Converter - log *logrus.Logger -} - -// New returns a new auth module -func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - server: srv, - db: db, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler) - return nil -} - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go deleted file mode 100644 index 99b79d470..000000000 --- a/internal/apimodule/app/appcreate.go +++ /dev/null @@ -1,119 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package app - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AppsPOSTHandler should be served at https://example.org/api/v1/apps -// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ -func (m *Module) AppsPOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "AppsPOSTHandler") - l.Trace("entering AppsPOSTHandler") - - form := &mastotypes.ApplicationPOSTRequest{} - if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - - // permitted length for most fields - permittedLength := 64 - // redirect can be a bit bigger because we probably need to encode data in the redirect uri - permittedRedirect := 256 - - // check lengths of fields before proceeding so the user can't spam huge entries into the database - if len(form.ClientName) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)}) - return - } - if len(form.Website) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)}) - return - } - if len(form.RedirectURIs) > permittedRedirect { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)}) - return - } - if len(form.Scopes) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)}) - return - } - - // set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ - var scopes string - if form.Scopes == "" { - scopes = "read" - } else { - scopes = form.Scopes - } - - // generate new IDs for this application and its associated client - clientID := uuid.NewString() - clientSecret := uuid.NewString() - vapidKey := uuid.NewString() - - // generate the application to put in the database - app := >smodel.Application{ - Name: form.ClientName, - Website: form.Website, - RedirectURI: form.RedirectURIs, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: scopes, - VapidKey: vapidKey, - } - - // chuck it in the db - if err := m.db.Put(app); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // now we need to model an oauth client from the application that the oauth library can use - oc := &oauth.Client{ - ID: clientID, - Secret: clientSecret, - Domain: form.RedirectURIs, - UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now - } - - // chuck it in the db - if err := m.db.Put(oc); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - mastoApp, err := m.mastoConverter.AppToMastoSensitive(app) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ - c.JSON(http.StatusOK, mastoApp) -} diff --git a/internal/apimodule/app/test/app_test.go b/internal/apimodule/app/test/app_test.go deleted file mode 100644 index d45b04e74..000000000 --- a/internal/apimodule/app/test/app_test.go +++ /dev/null @@ -1,21 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package app - -// TODO: write tests diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go deleted file mode 100644 index 341805b40..000000000 --- a/internal/apimodule/auth/auth.go +++ /dev/null @@ -1,88 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // AuthSignInPath is the API path for users to sign in through - AuthSignInPath = "/auth/sign_in" - // OauthTokenPath is the API path to use for granting token requests to users with valid credentials - OauthTokenPath = "/oauth/token" - // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user) - OauthAuthorizePath = "/oauth/authorize" -) - -// Module implements the ClientAPIModule interface for -type Module struct { - server oauth.Server - db db.DB - log *logrus.Logger -} - -// New returns a new auth module -func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - server: srv, - db: db, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler) - s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler) - - s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) - - s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) - s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) - - s.AttachMiddleware(m.OauthTokenMiddleware) - return nil -} - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go deleted file mode 100644 index 4bc1991ac..000000000 --- a/internal/apimodule/auth/authorize.go +++ /dev/null @@ -1,204 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "errors" - "fmt" - "net/http" - "net/url" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -) - -// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize -// The idea here is to present an oauth authorize page to the user, with a button -// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user -func (m *Module) AuthorizeGETHandler(c *gin.Context) { - l := m.log.WithField("func", "AuthorizeGETHandler") - s := sessions.Default(c) - - // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow - // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. - userID, ok := s.Get("userid").(string) - if !ok || userID == "" { - l.Trace("userid was empty, parsing form then redirecting to sign in page") - if err := parseAuthForm(c, l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } else { - c.Redirect(http.StatusFound, AuthSignInPath) - } - return - } - - // We can use the client_id on the session to retrieve info about the app associated with the client_id - clientID, ok := s.Get("client_id").(string) - if !ok || clientID == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) - return - } - app := >smodel.Application{ - ClientID: clientID, - } - if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) - return - } - - // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 - user := >smodel.User{ - ID: userID, - } - if err := m.db.GetByID(user.ID, user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acct := >smodel.Account{ - ID: user.AccountID, - } - - if err := m.db.GetByID(acct.ID, acct); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Finally we should also get the redirect and scope of this particular request, as stored in the session. - redirect, ok := s.Get("redirect_uri").(string) - if !ok || redirect == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"}) - return - } - scope, ok := s.Get("scope").(string) - if !ok || scope == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"}) - return - } - - // the authorize template will display a form to the user where they can get some information - // about the app that's trying to authorize, and the scope of the request. - // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler - l.Trace("serving authorize html") - c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ - "appname": app.Name, - "appwebsite": app.Website, - "redirect": redirect, - "scope": scope, - "user": acct.Username, - }) -} - -// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize -// At this point we assume that the user has A) logged in and B) accepted that the app should act for them, -// so we should proceed with the authentication flow and generate an oauth token for them if we can. -// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user -func (m *Module) AuthorizePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "AuthorizePOSTHandler") - s := sessions.Default(c) - - // At this point we know the user has said 'yes' to allowing the application and oauth client - // work for them, so we can set the - - // We need to retrieve the original form submitted to the authorizeGEThandler, and - // recreate it on the request so that it can be used further by the oauth2 library. - // So first fetch all the values from the session. - forceLogin, ok := s.Get("force_login").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"}) - return - } - responseType, ok := s.Get("response_type").(string) - if !ok || responseType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"}) - return - } - clientID, ok := s.Get("client_id").(string) - if !ok || clientID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"}) - return - } - redirectURI, ok := s.Get("redirect_uri").(string) - if !ok || redirectURI == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"}) - return - } - scope, ok := s.Get("scope").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"}) - return - } - userID, ok := s.Get("userid").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"}) - return - } - // we're done with the session so we can clear it now - s.Clear() - - // now set the values on the request - values := url.Values{} - values.Set("force_login", forceLogin) - values.Set("response_type", responseType) - values.Set("client_id", clientID) - values.Set("redirect_uri", redirectURI) - values.Set("scope", scope) - values.Set("userid", userID) - c.Request.Form = values - l.Tracef("values on request set to %+v", c.Request.Form) - - // and proceed with authorization using the oauth2 library - if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } -} - -// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores -// the values in the form into the session. -func parseAuthForm(c *gin.Context, l *logrus.Entry) error { - s := sessions.Default(c) - - // first make sure they've filled out the authorize form with the required values - form := &mastotypes.OAuthAuthorize{} - if err := c.ShouldBind(form); err != nil { - return err - } - l.Tracef("parsed form: %+v", form) - - // these fields are *required* so check 'em - if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { - return errors.New("missing one of: response_type, client_id or redirect_uri") - } - - // set default scope to read - if form.Scope == "" { - form.Scope = "read" - } - - // save these values from the form so we can use them elsewhere in the session - s.Set("force_login", form.ForceLogin) - s.Set("response_type", form.ResponseType) - s.Set("client_id", form.ClientID) - s.Set("redirect_uri", form.RedirectURI) - s.Set("scope", form.Scope) - return s.Save() -} diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go deleted file mode 100644 index 1d9a85993..000000000 --- a/internal/apimodule/auth/middleware.go +++ /dev/null @@ -1,76 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token. -// If so, it will check the User that the token belongs to, and set that in the context of -// the request. Then, it will look up the account for that user, and set that in the request too. -// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow -// public requests that don't have a Bearer token set (eg., for public instance information and so on). -func (m *Module) OauthTokenMiddleware(c *gin.Context) { - l := m.log.WithField("func", "ValidatePassword") - l.Trace("entering OauthTokenMiddleware") - - ti, err := m.server.ValidationBearerToken(c.Request) - if err != nil { - l.Trace("no valid token presented: continuing with unauthenticated request") - return - } - c.Set(oauth.SessionAuthorizedToken, ti) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti) - - // check for user-level token - if uid := ti.GetUserID(); uid != "" { - l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) - - // fetch user's and account for this user id - user := >smodel.User{} - if err := m.db.GetByID(uid, user); err != nil || user == nil { - l.Warnf("no user found for validated uid %s", uid) - return - } - c.Set(oauth.SessionAuthorizedUser, user) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) - - acct := >smodel.Account{} - if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil { - l.Warnf("no account found for validated user %s", uid) - return - } - c.Set(oauth.SessionAuthorizedAccount, acct) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct) - } - - // check for application token - if cid := ti.GetClientID(); cid != "" { - l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) - app := >smodel.Application{} - if err := m.db.GetWhere("client_id", cid, app); err != nil { - l.Tracef("no app found for client %s", cid) - } - c.Set(oauth.SessionAuthorizedApplication, app) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app) - } -} diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go deleted file mode 100644 index 44de0891c..000000000 --- a/internal/apimodule/auth/signin.go +++ /dev/null @@ -1,116 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "errors" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "golang.org/x/crypto/bcrypt" -) - -// login just wraps a form-submitted username (we want an email) and password -type login struct { - Email string `form:"username"` - Password string `form:"password"` -} - -// SignInGETHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler -func (m *Module) SignInGETHandler(c *gin.Context) { - m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) -} - -// SignInPOSTHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The handler will then redirect to the auth handler served at /auth -func (m *Module) SignInPOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "SignInPOSTHandler") - s := sessions.Default(c) - form := &login{} - if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("parsed form: %+v", form) - - userid, err := m.ValidatePassword(form.Email, form.Password) - if err != nil { - c.String(http.StatusForbidden, err.Error()) - return - } - - s.Set("userid", userid) - if err := s.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - l.Trace("redirecting to auth page") - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -// ValidatePassword takes an email address and a password. -// The goal is to authenticate the password against the one for that email -// address stored in the database. If OK, we return the userid (a uuid) for that user, -// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. -func (m *Module) ValidatePassword(email string, password string) (userid string, err error) { - l := m.log.WithField("func", "ValidatePassword") - - // make sure an email/password was provided and bail if not - if email == "" || password == "" { - l.Debug("email or password was not provided") - return incorrectPassword() - } - - // first we select the user from the database based on email address, bail if no user found for that email - gtsUser := >smodel.User{} - - if err := m.db.GetWhere("email", email, gtsUser); err != nil { - l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) - return incorrectPassword() - } - - // make sure a password is actually set and bail if not - if gtsUser.EncryptedPassword == "" { - l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) - return incorrectPassword() - } - - // compare the provided password with the encrypted one from the db, bail if they don't match - if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { - l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) - return incorrectPassword() - } - - // If we've made it this far the email/password is correct, so we can just return the id of the user. - userid = gtsUser.ID - l.Tracef("returning (%s, %s)", userid, err) - return -} - -// incorrectPassword is just a little helper function to use in the ValidatePassword function -func incorrectPassword() (string, error) { - return "", errors.New("password/email combination was incorrect") -} diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/apimodule/auth/test/auth_test.go deleted file mode 100644 index 2c272e985..000000000 --- a/internal/apimodule/auth/test/auth_test.go +++ /dev/null @@ -1,166 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "context" - "fmt" - "testing" - - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "golang.org/x/crypto/bcrypt" -) - -type AuthTestSuite struct { - suite.Suite - oauthServer oauth.Server - db db.DB - testAccount *gtsmodel.Account - testApplication *gtsmodel.Application - testUser *gtsmodel.User - testClient *oauth.Client - config *config.Config -} - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AuthTestSuite) SetupSuite() { - c := config.Empty() - // we're running on localhost without https so set the protocol to http - c.Protocol = "http" - // just for testing - c.Host = "localhost:8080" - // because go tests are run within the test package directory, we need to fiddle with the templateconfig - // basedir in a way that we wouldn't normally have to do when running the binary, in order to make - // the templates actually load - c.TemplateConfig.BaseDir = "../../../web/template/" - c.DBConfig = &config.DBConfig{ - Type: "postgres", - Address: "localhost", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - ApplicationName: "gotosocial", - } - suite.config = c - - encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) - if err != nil { - logrus.Panicf("error encrypting user pass: %s", err) - } - - acctID := uuid.NewString() - - suite.testAccount = >smodel.Account{ - ID: acctID, - Username: "test_user", - } - suite.testUser = >smodel.User{ - EncryptedPassword: string(encryptedPassword), - Email: "user@example.org", - AccountID: acctID, - } - suite.testClient = &oauth.Client{ - ID: "a-known-client-id", - Secret: "some-secret", - Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), - } - suite.testApplication = >smodel.Application{ - 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: uuid.NewString(), - } -} - -// SetupTest creates a postgres connection and creates the oauth_clients table before each test -func (suite *AuthTestSuite) SetupTest() { - - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - db, err := db.New(context.Background(), suite.config, log) - if err != nil { - logrus.Panicf("error creating database connection: %s", err) - } - - suite.db = db - - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - - for _, m := range models { - if err := suite.db.CreateTable(m); err != nil { - logrus.Panicf("db connection error: %s", err) - } - } - - suite.oauthServer = oauth.New(suite.db, log) - - if err := suite.db.Put(suite.testAccount); err != nil { - logrus.Panicf("could not insert test account into db: %s", err) - } - if err := suite.db.Put(suite.testUser); err != nil { - logrus.Panicf("could not insert test user into db: %s", err) - } - if err := suite.db.Put(suite.testClient); err != nil { - logrus.Panicf("could not insert test client into db: %s", err) - } - if err := suite.db.Put(suite.testApplication); err != nil { - logrus.Panicf("could not insert test application into db: %s", err) - } - -} - -// TearDownTest drops the oauth_clients table and closes the pg connection after each test -func (suite *AuthTestSuite) TearDownTest() { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - for _, m := range models { - if err := suite.db.DropTable(m); err != nil { - logrus.Panicf("error dropping table: %s", err) - } - } - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } - suite.db = nil -} - -func TestAuthTestSuite(t *testing.T) { - suite.Run(t, new(AuthTestSuite)) -} diff --git a/internal/apimodule/auth/token.go b/internal/apimodule/auth/token.go deleted file mode 100644 index c531a3009..000000000 --- a/internal/apimodule/auth/token.go +++ /dev/null @@ -1,36 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token -// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. -// See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token -func (m *Module) TokenPOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "TokenPOSTHandler") - l.Trace("entered TokenPOSTHandler") - if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } -} diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go deleted file mode 100644 index 7651c8cc1..000000000 --- a/internal/apimodule/fileserver/fileserver.go +++ /dev/null @@ -1,84 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package fileserver - -import ( - "fmt" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/storage" -) - -const ( - // AccountIDKey is the url key for account id (an account uuid) - AccountIDKey = "account_id" - // MediaTypeKey is the url key for media type (usually something like attachment or header etc) - MediaTypeKey = "media_type" - // MediaSizeKey is the url key for the desired media size--original/small/static - MediaSizeKey = "media_size" - // FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg - FileNameKey = "file_name" -) - -// 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 { - return &FileServer{ - config: config, - db: db, - storage: storage, - log: log, - storageBase: config.StorageConfig.ServeBasePath, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *FileServer) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *FileServer) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.MediaAttachment{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go deleted file mode 100644 index 0421c5095..000000000 --- a/internal/apimodule/fileserver/servefile.go +++ /dev/null @@ -1,243 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package fileserver - -import ( - "bytes" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" -) - -// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. -// -// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". -// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. -func (m *FileServer) ServeFile(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "ServeFile", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Trace("received request") - - // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: - // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" - // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. - accountID := c.Param(AccountIDKey) - if accountID == "" { - l.Debug("missing accountID from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - mediaType := c.Param(MediaTypeKey) - if mediaType == "" { - l.Debug("missing mediaType from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - mediaSize := c.Param(MediaSizeKey) - if mediaSize == "" { - l.Debug("missing mediaSize from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - fileName := c.Param(FileNameKey) - if fileName == "" { - l.Debug("missing fileName from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - // Only serve media types that are defined in our internal media module - switch mediaType { - case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: - m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) - return - case media.MediaEmoji: - m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) - return - } - l.Debugf("mediatype %s not recognized", mediaType) - c.String(http.StatusNotFound, "404 page not found") -} - -func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { - l := m.log.WithFields(logrus.Fields{ - "func": "serveAttachment", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static - switch mediaSize { - case media.MediaOriginal, media.MediaSmall, media.MediaStatic: - default: - l.Debugf("mediasize %s not recognized", mediaSize) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // derive the media id and the file extension from the last part of the request - spl := strings.Split(fileName, ".") - if len(spl) != 2 { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - wantedMediaID := spl[0] - fileExtension := spl[1] - if wantedMediaID == "" || fileExtension == "" { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db - attachment := >smodel.MediaAttachment{} - if err := m.db.GetByID(wantedMediaID, attachment); err != nil { - l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // make sure the given account id owns the requested attachment - if accountID != attachment.AccountID { - l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment - var storagePath string - var contentType string - var contentLength int - switch mediaSize { - case media.MediaOriginal: - storagePath = attachment.File.Path - contentType = attachment.File.ContentType - contentLength = attachment.File.FileSize - case media.MediaSmall: - storagePath = attachment.Thumbnail.Path - contentType = attachment.Thumbnail.ContentType - contentLength = attachment.Thumbnail.FileSize - } - - // use the path listed on the attachment we pulled out of the database to retrieve the object from storage - attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) - if err != nil { - l.Debugf("error retrieving from storage: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) - - // finally we can return with all the information we derived above - c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) -} - -func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { - l := m.log.WithFields(logrus.Fields{ - "func": "serveEmoji", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // This corresponds to original-sized emoji as it was uploaded, or static - switch mediaSize { - case media.MediaOriginal, media.MediaStatic: - default: - l.Debugf("mediasize %s not recognized", mediaSize) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // derive the media id and the file extension from the last part of the request - spl := strings.Split(fileName, ".") - if len(spl) != 2 { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - wantedEmojiID := spl[0] - fileExtension := spl[1] - if wantedEmojiID == "" || fileExtension == "" { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db - emoji := >smodel.Emoji{} - if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { - l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // make sure the instance account id owns the requested emoji - instanceAccount := >smodel.Account{} - if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { - l.Debugf("error fetching instance account: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - if accountID != instanceAccount.ID { - l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment - var storagePath string - var contentType string - var contentLength int - switch mediaSize { - case media.MediaOriginal: - storagePath = emoji.ImagePath - contentType = emoji.ImageContentType - contentLength = emoji.ImageFileSize - case media.MediaStatic: - storagePath = emoji.ImageStaticPath - contentType = "image/png" - contentLength = emoji.ImageStaticFileSize - } - - // use the path listed on the emoji we pulled out of the database to retrieve the object from storage - emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) - if err != nil { - l.Debugf("error retrieving emoji from storage: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // finally we can return with all the information we derived above - c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) -} diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go deleted file mode 100644 index 516e3528c..000000000 --- a/internal/apimodule/fileserver/test/servefile_test.go +++ /dev/null @@ -1,157 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type ServeFileTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // item being tested - fileServer *fileserver.FileServer -} - -/* - TEST INFRASTRUCTURE -*/ - -func (suite *ServeFileTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - - // setup module being tested - suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) -} - -func (suite *ServeFileTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -func (suite *ServeFileTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() -} - -func (suite *ServeFileTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { - targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] - assert.True(suite.T(), ok) - assert.NotNil(suite.T(), targetAttachment) - - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) - - // normally the router would populate these params from the path values, - // but because we're calling the ServeFile function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: fileserver.AccountIDKey, - Value: targetAttachment.AccountID, - }, - gin.Param{ - Key: fileserver.MediaTypeKey, - Value: media.MediaAttachment, - }, - gin.Param{ - Key: fileserver.MediaSizeKey, - Value: media.MediaOriginal, - }, - gin.Param{ - Key: fileserver.FileNameKey, - Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), - }, - } - - // call the function we're testing and check status code - suite.fileServer.ServeFile(ctx) - suite.EqualValues(http.StatusOK, recorder.Code) - - b, err := ioutil.ReadAll(recorder.Body) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), b) - - fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), fileInStorage) - assert.Equal(suite.T(), b, fileInStorage) -} - -func TestServeFileTestSuite(t *testing.T) { - suite.Run(t, new(ServeFileTestSuite)) -} diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go deleted file mode 100644 index 8fb9f16ec..000000000 --- a/internal/apimodule/media/media.go +++ /dev/null @@ -1,76 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( - "fmt" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// BasePath is the base API path for making media requests -const BasePath = "/api/v1/media" - -// Module implements the ClientAPIModule interface for media -type Module struct { - mediaHandler media.Handler - config *config.Config - db db.DB - mastoConverter mastotypes.Converter - log *logrus.Logger -} - -// New returns a new auth module -func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - mediaHandler: mediaHandler, - config: config, - db: db, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.MediaAttachment{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go deleted file mode 100644 index ee713a471..000000000 --- a/internal/apimodule/media/mediacreate.go +++ /dev/null @@ -1,193 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/config" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// MediaCreatePOSTHandler handles requests to create/upload media attachments -func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "statusCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything* - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - // First check this user/account is permitted to create media - // There's no point continuing otherwise. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) - return - } - - // extract the media create form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) - form := &mastotypes.AttachmentRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) - return - } - - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) - if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // open the attachment and extract the bytes from it - f, err := form.File.Open() - if err != nil { - l.Debugf("error opening attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) - return - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - l.Debugf("error reading attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) - return - } - if size == 0 { - l.Debug("could not read provided attachment: size 0 bytes") - c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) - return - } - - // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) - if err != nil { - l.Debugf("error reading attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) - return - } - - // now we need to add extra fields that the attachment processor doesn't know (from the form) - // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) - - // first description - attachment.Description = form.Description - - // now parse the focus parameter - // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated - var focusx, focusy float32 - if form.Focus != "" { - spl := strings.Split(form.Focus, ",") - if len(spl) != 2 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - xStr := spl[0] - yStr := spl[1] - if xStr == "" || yStr == "" { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - fx, err := strconv.ParseFloat(xStr, 32) - if err != nil { - l.Debugf("improperly formatted focus %s: %s", form.Focus, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - if fx > 1 || fx < -1 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - focusx = float32(fx) - fy, err := strconv.ParseFloat(yStr, 32) - if err != nil { - l.Debugf("improperly formatted focus %s: %s", form.Focus, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - if fy > 1 || fy < -1 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - focusy = float32(fy) - } - attachment.FileMeta.Focus.X = focusx - attachment.FileMeta.Focus.Y = focusy - - // prepare the frontend representation now -- if there are any errors here at least we can bail without - // having already put something in the database and then having to clean it up again (eugh) - mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment) - if err != nil { - l.Debugf("error parsing media attachment to frontend type: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) - return - } - - // now we can confidently put the attachment in the database - if err := m.db.Put(attachment); err != nil { - l.Debugf("error storing media attachment in db: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) - return - } - - // and return its frontend representation - c.JSON(http.StatusAccepted, mastoAttachment) -} - -func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error { - // check there actually is a file attached and it's not size 0 - if form.File == nil || form.File.Size == 0 { - return errors.New("no attachment given") - } - - // a very superficial check to see if no size limits are exceeded - // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there - maxSize := config.MaxVideoSize - if config.MaxImageSize > maxSize { - maxSize = config.MaxImageSize - } - if form.File.Size > int64(maxSize) { - return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) - } - - if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { - return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) - } - - // TODO: validate focus here - - return nil -} diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go deleted file mode 100644 index 30bbb117a..000000000 --- a/internal/apimodule/media/test/mediacreate_test.go +++ /dev/null @@ -1,194 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type MediaCreateTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // item being tested - mediaModule *mediamodule.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -func (suite *MediaCreateTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - - // setup module being tested - suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module) -} - -func (suite *MediaCreateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -func (suite *MediaCreateTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() -} - -func (suite *MediaCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { - - // set up the context for the request - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - - // see what's in storage *before* the request - storageKeysBeforeRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ - "description": "this is a test image -- a cool background from somewhere", - "focus": "-0.5,0.5", - }) - if err != nil { - panic(err) - } - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting - ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) - - // do the actual request - suite.mediaModule.MediaCreatePOSTHandler(ctx) - - // check what's in storage *after* the request - storageKeysAfterRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // check response - suite.EqualValues(http.StatusAccepted, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - fmt.Println(string(b)) - - attachmentReply := &mastomodel.Attachment{} - err = json.Unmarshal(b, attachmentReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description) - assert.Equal(suite.T(), "image", attachmentReply.Type) - assert.EqualValues(suite.T(), mastomodel.MediaMeta{ - Original: mastomodel.MediaDimensions{ - Width: 1920, - Height: 1080, - Size: "1920x1080", - Aspect: 1.7777778, - }, - Small: mastomodel.MediaDimensions{ - Width: 256, - Height: 144, - Size: "256x144", - Aspect: 1.7777778, - }, - Focus: mastomodel.MediaFocus{ - X: -0.5, - Y: 0.5, - }, - }, attachmentReply.Meta) - assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash) - assert.NotEmpty(suite.T(), attachmentReply.ID) - assert.NotEmpty(suite.T(), attachmentReply.URL) - assert.NotEmpty(suite.T(), attachmentReply.PreviewURL) - assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail -} - -func TestMediaCreateTestSuite(t *testing.T) { - suite.Run(t, new(MediaCreateTestSuite)) -} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go deleted file mode 100644 index 2d4293d0e..000000000 --- a/internal/apimodule/mock_ClientAPIModule.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package apimodule - -import ( - mock "github.com/stretchr/testify/mock" - db "github.com/superseriousbusiness/gotosocial/internal/db" - - router "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type -type MockClientAPIModule struct { - mock.Mock -} - -// CreateTables provides a mock function with given fields: _a0 -func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(db.DB) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Route provides a mock function with given fields: s -func (_m *MockClientAPIModule) Route(s router.Router) error { - ret := _m.Called(s) - - var r0 error - if rf, ok := ret.Get(0).(func(router.Router) error); ok { - r0 = rf(s) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/internal/apimodule/security/flocblock.go b/internal/apimodule/security/flocblock.go deleted file mode 100644 index 7cedcde6b..000000000 --- a/internal/apimodule/security/flocblock.go +++ /dev/null @@ -1,28 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package security - -import "github.com/gin-gonic/gin" - -// FlocBlock is a middleware that prevents google chrome cohort tracking by -// writing the Permissions-Policy header after all other parts of the request have been completed. -// See: https://plausible.io/blog/google-floc -func (m *Module) FlocBlock(c *gin.Context) { - c.Header("Permissions-Policy", "interest-cohort=()") -} diff --git a/internal/apimodule/security/security.go b/internal/apimodule/security/security.go deleted file mode 100644 index 8f805bc93..000000000 --- a/internal/apimodule/security/security.go +++ /dev/null @@ -1,52 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package security - -import ( - "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" -) - -// Module implements the ClientAPIModule interface for security middleware -type Module struct { - config *config.Config - log *logrus.Logger -} - -// New returns a new security module -func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - config: config, - log: log, - } -} - -// Route attaches security middleware to the given router -func (m *Module) Route(s router.Router) error { - s.AttachMiddleware(m.FlocBlock) - return nil -} - -// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface -func (m *Module) CreateTables(db db.DB) error { - return nil -} diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go deleted file mode 100644 index 73a1b5847..000000000 --- a/internal/apimodule/status/status.go +++ /dev/null @@ -1,158 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // IDKey is for status UUIDs - IDKey = "id" - // BasePath is the base path for serving the status API - BasePath = "/api/v1/statuses" - // BasePathWithID is just the base path with the ID key in it. - // Use this anywhere you need to know the ID of the status being queried. - BasePathWithID = BasePath + "/:" + IDKey - - // ContextPath is used for fetching context of posts - ContextPath = BasePathWithID + "/context" - - // FavouritedPath is for seeing who's faved a given status - FavouritedPath = BasePathWithID + "/favourited_by" - // FavouritePath is for posting a fave on a status - FavouritePath = BasePathWithID + "/favourite" - // UnfavouritePath is for removing a fave from a status - UnfavouritePath = BasePathWithID + "/unfavourite" - - // RebloggedPath is for seeing who's boosted a given status - RebloggedPath = BasePathWithID + "/reblogged_by" - // ReblogPath is for boosting/reblogging a given status - ReblogPath = BasePathWithID + "/reblog" - // UnreblogPath is for undoing a boost/reblog of a given status - UnreblogPath = BasePathWithID + "/unreblog" - - // BookmarkPath is for creating a bookmark on a given status - BookmarkPath = BasePathWithID + "/bookmark" - // UnbookmarkPath is for removing a bookmark from a given status - UnbookmarkPath = BasePathWithID + "/unbookmark" - - // MutePath is for muting a given status so that notifications will no longer be received about it. - MutePath = BasePathWithID + "/mute" - // UnmutePath is for undoing an existing mute - UnmutePath = BasePathWithID + "/unmute" - - // PinPath is for pinning a status to an account profile so that it's the first thing people see - PinPath = BasePathWithID + "/pin" - // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy - UnpinPath = BasePathWithID + "/unpin" -) - -// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses -type Module struct { - config *config.Config - db db.DB - mediaHandler media.Handler - mastoConverter mastotypes.Converter - distributor distributor.Distributor - log *logrus.Logger -} - -// New returns a new account module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - distributor: distributor, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) - r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) - - r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) - r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) - - r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Block{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.StatusFave{}, - >smodel.StatusBookmark{}, - >smodel.StatusMute{}, - >smodel.StatusPin{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - >smodel.Emoji{}, - >smodel.Tag{}, - >smodel.Mention{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} - -// muxHandler is a little workaround to overcome the limitations of Gin -func (m *Module) muxHandler(c *gin.Context) { - m.log.Debug("entering mux handler") - ru := c.Request.RequestURI - - switch c.Request.Method { - case http.MethodGet: - if strings.HasPrefix(ru, ContextPath) { - // TODO - } else if strings.HasPrefix(ru, FavouritedPath) { - m.StatusFavedByGETHandler(c) - } else { - m.StatusGETHandler(c) - } - } -} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go deleted file mode 100644 index 97354e767..000000000 --- a/internal/apimodule/status/statuscreate.go +++ /dev/null @@ -1,462 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "errors" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -type advancedStatusCreateForm struct { - mastotypes.StatusCreateRequest - advancedVisibilityFlagsForm -} - -type advancedVisibilityFlagsForm struct { - // The gotosocial visibility model - VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"` - // This status will be federated beyond the local timeline(s) - Federated *bool `form:"federated"` - // This status can be boosted/reblogged - Boostable *bool `form:"boostable"` - // This status can be replied to - Replyable *bool `form:"replyable"` - // This status can be liked/faved - Likeable *bool `form:"likeable"` -} - -// StatusCreatePOSTHandler deals with the creation of new statuses -func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "statusCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - // First check this user/account is permitted to post new statuses. - // There's no point continuing otherwise. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) - return - } - - // extract the status create form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) - form := &advancedStatusCreateForm{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) - return - } - - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) - if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // At this point we know the account is permitted to post, and we know the request form - // is valid (at least according to the API specifications and the instance configuration). - // So now we can start digging a bit deeper into the form and building up the new status from it. - - // first we create a new status and add some basic info to it - uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host) - thisStatusID := uuid.NewString() - thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) - thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) - newStatus := >smodel.Status{ - ID: thisStatusID, - URI: thisStatusURI, - URL: thisStatusURL, - Content: util.HTMLFormat(form.Status), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Local: true, - AccountID: authed.Account.ID, - ContentWarning: form.SpoilerText, - ActivityStreamsType: gtsmodel.ActivityStreamsNote, - Sensitive: form.Sensitive, - Language: form.Language, - CreatedWithApplicationID: authed.Application.ID, - Text: form.Status, - } - - // check if replyToID is ok - if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // check if mediaIDs are ok - if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // check if visibility settings are ok - if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // handle language settings - if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // handle mentions - if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - /* - FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it - */ - - // put the new status in the database, generating an ID for it in the process - if err := m.db.Put(newStatus); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // change the status ID of the media attachments to the new status - for _, a := range newStatus.GTSMediaAttachments { - a.StatusID = newStatus.ID - a.UpdatedAt = time.Now() - if err := m.db.UpdateByID(a.ID, a); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsCreate, - Activity: newStatus, - } - - // return the frontend representation of the new status to the submitter - mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, mastoStatus) -} - -func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { - // validate that, structurally, we have a valid status/post - if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { - return errors.New("no status, media, or poll provided") - } - - if form.MediaIDs != nil && form.Poll != nil { - return errors.New("can't post media + poll in same status") - } - - // validate status - if form.Status != "" { - if len(form.Status) > config.MaxChars { - return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) - } - } - - // validate media attachments - if len(form.MediaIDs) > config.MaxMediaFiles { - return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) - } - - // validate poll - if form.Poll != nil { - if form.Poll.Options == nil { - return errors.New("poll with no options") - } - if len(form.Poll.Options) > config.PollMaxOptions { - return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) - } - for _, p := range form.Poll.Options { - if len(p) > config.PollOptionMaxChars { - return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) - } - } - } - - // validate spoiler text/cw - if form.SpoilerText != "" { - if len(form.SpoilerText) > config.CWMaxChars { - return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) - } - } - - // validate post language - if form.Language != "" { - if err := util.ValidateLanguage(form.Language); err != nil { - return err - } - } - - return nil -} - -func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { - // by default all flags are set to true - gtsAdvancedVis := >smodel.VisibilityAdvanced{ - Federated: true, - Boostable: true, - Replyable: true, - Likeable: true, - } - - var gtsBasicVis gtsmodel.Visibility - // Advanced takes priority if it's set. - // If it's not set, take whatever masto visibility is set. - // If *that's* not set either, then just take the account default. - // If that's also not set, take the default for the whole instance. - if form.VisibilityAdvanced != nil { - gtsBasicVis = *form.VisibilityAdvanced - } else if form.Visibility != "" { - gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) - } else if accountDefaultVis != "" { - gtsBasicVis = accountDefaultVis - } else { - gtsBasicVis = gtsmodel.VisibilityDefault - } - - switch gtsBasicVis { - case gtsmodel.VisibilityPublic: - // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out - break - case gtsmodel.VisibilityUnlocked: - // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Boostable != nil { - gtsAdvancedVis.Boostable = *form.Boostable - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: - // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them - gtsAdvancedVis.Boostable = false - - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityDirect: - // direct is pretty easy: there's only one possible setting so return it - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Boostable = false - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Likeable = true - } - - status.Visibility = gtsBasicVis - status.VisibilityAdvanced = gtsAdvancedVis - return nil -} - -func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.InReplyToID == "" { - return nil - } - - // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: - // - // 1. Does the replied status exist in the database? - // 2. Is the replied status marked as replyable? - // 3. Does a block exist between either the current account or the account that posted the status it's replying to? - // - // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. - repliedStatus := >smodel.Status{} - repliedAccount := >smodel.Account{} - // check replied status exists + is replyable - if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - - if !repliedStatus.VisibilityAdvanced.Replyable { - return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) - } - - // check replied account is known to us - if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - // check if a block exists - if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - } else if blocked { - return fmt.Errorf("status with id %s not replyable", form.InReplyToID) - } - status.InReplyToID = repliedStatus.ID - status.InReplyToAccountID = repliedAccount.ID - - return nil -} - -func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.MediaIDs == nil { - return nil - } - - gtsMediaAttachments := []*gtsmodel.MediaAttachment{} - attachments := []string{} - for _, mediaID := range form.MediaIDs { - // check these attachments exist - a := >smodel.MediaAttachment{} - if err := m.db.GetByID(mediaID, a); err != nil { - return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) - } - // check they belong to the requesting account id - if a.AccountID != thisAccountID { - return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) - } - // check they're not already used in a status - if a.StatusID != "" || a.ScheduledStatusID != "" { - return fmt.Errorf("media with id %s is already attached to a status", mediaID) - } - gtsMediaAttachments = append(gtsMediaAttachments, a) - attachments = append(attachments, a.ID) - } - status.GTSMediaAttachments = gtsMediaAttachments - status.Attachments = attachments - return nil -} - -func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { - if form.Language != "" { - status.Language = form.Language - } else { - status.Language = accountDefaultLanguage - } - if status.Language == "" { - return errors.New("no language given either in status create form or account default") - } - return nil -} - -func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - menchies := []string{} - gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating mentions from status: %s", err) - } - for _, menchie := range gtsMenchies { - if err := m.db.Put(menchie); err != nil { - return fmt.Errorf("error putting mentions in db: %s", err) - } - menchies = append(menchies, menchie.TargetAccountID) - } - // add full populated gts menchies to the status for passing them around conveniently - status.GTSMentions = gtsMenchies - // add just the ids of the mentioned accounts to the status for putting in the db - status.Mentions = menchies - return nil -} - -func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - tags := []string{} - gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating hashtags from status: %s", err) - } - for _, tag := range gtsTags { - if err := m.db.Upsert(tag, "name"); err != nil { - return fmt.Errorf("error putting tags in db: %s", err) - } - tags = append(tags, tag.ID) - } - // add full populated gts tags to the status for passing them around conveniently - status.GTSTags = gtsTags - // add just the ids of the used tags to the status for putting in the db - status.Tags = tags - return nil -} - -func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - emojis := []string{} - gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating emojis from status: %s", err) - } - for _, e := range gtsEmojis { - emojis = append(emojis, e.ID) - } - // add full populated gts emojis to the status for passing them around conveniently - status.GTSEmojis = gtsEmojis - // add just the ids of the used emojis to the status for putting in the db - status.Emojis = emojis - return nil -} diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go deleted file mode 100644 index 01dfe81df..000000000 --- a/internal/apimodule/status/statusdelete.go +++ /dev/null @@ -1,107 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusDELETEHandler verifies and handles deletion of a status -func (m *Module) StatusDELETEHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusDELETEHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't delete status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if targetStatus.AccountID != authed.Account.ID { - l.Debug("status doesn't belong to requesting account") - c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { - l.Errorf("error deleting status from the database: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsDelete, - Activity: targetStatus, - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go deleted file mode 100644 index 9ce68af09..000000000 --- a/internal/apimodule/status/statusfave.go +++ /dev/null @@ -1,137 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavePOSTHandler handles fave requests against a given status ID -func (m *Module) StatusFavePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusFavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't fave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // is the status faveable? - if !targetStatus.VisibilityAdvanced.Likeable { - l.Debug("status is not faveable") - c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) - return - } - - // it's visible! it's faveable! so let's fave the FUCK out of it - fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) - if err != nil { - l.Debugf("error faveing status: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // if the targeted status was already faved, faved will be nil - // only put the fave in the distributor if something actually changed - if fave != nil { - fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, // status is a note - APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note - Activity: fave, // pass the fave along for processing - } - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go deleted file mode 100644 index 58236edc2..000000000 --- a/internal/apimodule/status/statusfavedby.go +++ /dev/null @@ -1,129 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status -func (m *Module) StatusFavedByGETHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - var requestingAccount *gtsmodel.Account - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed but will continue to serve anyway if public status") - requestingAccount = nil - } else { - requestingAccount = authed.Account - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff - favingAccounts, err := m.db.WhoFavedStatus(targetStatus) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // filter the list so the user doesn't see accounts they blocked or which blocked them - filteredAccounts := []*gtsmodel.Account{} - for _, acc := range favingAccounts { - blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if !blocked { - filteredAccounts = append(filteredAccounts, acc) - } - } - - // TODO: filter other things here? suspended? muted? silenced? - - // now we can return the masto representation of those accounts - mastoAccounts := []*mastotypes.Account{} - for _, acc := range filteredAccounts { - mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoAccounts = append(mastoAccounts, mastoAccount) - } - - c.JSON(http.StatusOK, mastoAccounts) -} diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go deleted file mode 100644 index 76918c782..000000000 --- a/internal/apimodule/status/statusget.go +++ /dev/null @@ -1,112 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusGETHandler is for handling requests to just get one status based on its ID -func (m *Module) StatusGETHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - var requestingAccount *gtsmodel.Account - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed but will continue to serve anyway if public status") - requestingAccount = nil - } else { - requestingAccount = authed.Account - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go deleted file mode 100644 index 9c06eaf92..000000000 --- a/internal/apimodule/status/statusunfave.go +++ /dev/null @@ -1,137 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID -func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusUnfavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't unfave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // is the status faveable? - if !targetStatus.VisibilityAdvanced.Likeable { - l.Debug("status is not faveable") - c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) - return - } - - // it's visible! it's faveable! so let's unfave the FUCK out of it - fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) - if err != nil { - l.Debugf("error unfaveing status: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // fave might be nil if this status wasn't faved in the first place - // we only want to pass the message to the distributor if something actually changed - if fave != nil { - fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, // status is a note - APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave - Activity: fave, // pass the undone fave along - } - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go deleted file mode 100644 index d143ac9a7..000000000 --- a/internal/apimodule/status/test/statuscreate_test.go +++ /dev/null @@ -1,346 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusCreateTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // module being tested - statusModule *status.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *StatusCreateTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusCreateTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusCreateTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusCreatePOSTHandler -*/ - -// Post a new status with some custom visibility settings -func (suite *StatusCreateTestSuite) TestPostNewStatus() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"this is a brand new status! #helloworld"}, - "spoiler_text": {"hello hello"}, - "sensitive": {"true"}, - "visibility_advanced": {"mutuals_only"}, - "likeable": {"false"}, - "replyable": {"false"}, - "federated": {"false"}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - - // 1. we should have OK from our call to the function - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) - assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) - assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) - assert.Len(suite.T(), statusReply.Tags, 1) - assert.Equal(suite.T(), mastomodel.Tag{ - Name: "helloworld", - URL: "http://localhost:8080/tags/helloworld", - }, statusReply.Tags[0]) - - gtsTag := >smodel.Tag{} - err = suite.db.GetWhere("name", "helloworld", gtsTag) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) -} - -func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "", statusReply.SpoilerText) - assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content) - - assert.Len(suite.T(), statusReply.Emojis, 1) - mastoEmoji := statusReply.Emojis[0] - gtsEmoji := testrig.NewTestEmojis()["rainbow"] - - assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode) - assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL) - assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL) -} - -// Try to reply to a status that doesn't exist -func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"this is a reply to a status that doesn't exist"}, - "spoiler_text": {"don't open cuz it won't work"}, - "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) -} - -// Post a reply to the status of a local user that allows replies. -func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)}, - "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "", statusReply.SpoilerText) - assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) - assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) - assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID) - assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID) - assert.Len(suite.T(), statusReply.Mentions, 1) -} - -// Take a media file which is currently not associated with a status, and attach it to a new status. -func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"here's an image attachment"}, - "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - fmt.Println(string(b)) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "", statusReply.SpoilerText) - assert.Equal(suite.T(), "here's an image attachment", statusReply.Content) - assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) - - // there should be one media attachment - assert.Len(suite.T(), statusReply.MediaAttachments, 1) - - // get the updated media attachment from the database - gtsAttachment := >smodel.MediaAttachment{} - err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment) - assert.NoError(suite.T(), err) - - // convert it to a masto attachment - gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) - assert.NoError(suite.T(), err) - - // compare it with what we have now - assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto) - - // the status id of the attachment should now be set to the id of the status we just created - assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID) -} - -func TestStatusCreateTestSuite(t *testing.T) { - suite.Run(t, new(StatusCreateTestSuite)) -} diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/apimodule/status/test/statusfave_test.go deleted file mode 100644 index 9ccf58948..000000000 --- a/internal/apimodule/status/test/statusfave_test.go +++ /dev/null @@ -1,207 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusFaveTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *StatusFaveTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFaveTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFaveTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - 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() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusFaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -// fave a status -func (suite *StatusFaveTestSuite) TestPostFave() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - - // normally the router would populate these params from the path values, - // but because we're calling the function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) - assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) - assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) - assert.True(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 1, statusReply.FavouritesCount) -} - -// try to fave a status that's not faveable -func (suite *StatusFaveTestSuite) TestPostUnfaveable() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - - // normally the router would populate these params from the path values, - // but because we're calling the function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b)) -} - -func TestStatusFaveTestSuite(t *testing.T) { - suite.Run(t, new(StatusFaveTestSuite)) -} diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/apimodule/status/test/statusfavedby_test.go deleted file mode 100644 index 169543a81..000000000 --- a/internal/apimodule/status/test/statusfavedby_test.go +++ /dev/null @@ -1,159 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusFavedByTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module -} - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *StatusFavedByTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFavedByTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFavedByTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - 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() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusFavedByTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *StatusFavedByTestSuite) TestGetFavedBy() { - t := suite.testTokens["local_account_2"] - oauthToken := oauth.TokenToOauthToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - - // normally the router would populate these params from the path values, - // but because we're calling the function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavedByGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - accts := []mastomodel.Account{} - err = json.Unmarshal(b, &accts) - assert.NoError(suite.T(), err) - - assert.Len(suite.T(), accts, 1) - assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username) -} - -func TestStatusFavedByTestSuite(t *testing.T) { - suite.Run(t, new(StatusFavedByTestSuite)) -} diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/apimodule/status/test/statusget_test.go deleted file mode 100644 index ce817d247..000000000 --- a/internal/apimodule/status/test/statusget_test.go +++ /dev/null @@ -1,168 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "testing" - - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusGetTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // module being tested - statusModule *status.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *StatusGetTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusGetTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusGetTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusGetTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusGetPOSTHandler -*/ - -// Post a new status with some custom visibility settings -func (suite *StatusGetTestSuite) TestPostNewStatus() { - - // t := suite.testTokens["local_account_1"] - // oauthToken := oauth.PGTokenToOauthToken(t) - - // // setup - // recorder := httptest.NewRecorder() - // ctx, _ := gin.CreateTestContext(recorder) - // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - // ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting - // ctx.Request.Form = url.Values{ - // "status": {"this is a brand new status! #helloworld"}, - // "spoiler_text": {"hello hello"}, - // "sensitive": {"true"}, - // "visibility_advanced": {"mutuals_only"}, - // "likeable": {"false"}, - // "replyable": {"false"}, - // "federated": {"false"}, - // } - // suite.statusModule.statusGETHandler(ctx) - - // // check response - - // // 1. we should have OK from our call to the function - // suite.EqualValues(http.StatusOK, recorder.Code) - - // result := recorder.Result() - // defer result.Body.Close() - // b, err := ioutil.ReadAll(result.Body) - // assert.NoError(suite.T(), err) - - // statusReply := &mastomodel.Status{} - // err = json.Unmarshal(b, statusReply) - // assert.NoError(suite.T(), err) - - // assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) - // assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) - // assert.True(suite.T(), statusReply.Sensitive) - // assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) - // assert.Len(suite.T(), statusReply.Tags, 1) - // assert.Equal(suite.T(), mastomodel.Tag{ - // Name: "helloworld", - // URL: "http://localhost:8080/tags/helloworld", - // }, statusReply.Tags[0]) - - // gtsTag := >smodel.Tag{} - // err = suite.db.GetWhere("name", "helloworld", gtsTag) - // assert.NoError(suite.T(), err) - // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) -} - -func TestStatusGetTestSuite(t *testing.T) { - suite.Run(t, new(StatusGetTestSuite)) -} diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/apimodule/status/test/statusunfave_test.go deleted file mode 100644 index 5f5277921..000000000 --- a/internal/apimodule/status/test/statusunfave_test.go +++ /dev/null @@ -1,219 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusUnfaveTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *StatusUnfaveTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusUnfaveTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusUnfaveTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - 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() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusUnfaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -// unfave a status -func (suite *StatusUnfaveTestSuite) TestPostUnfave() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // this is the status we wanna unfave: in the testrig it's already faved by this account - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - - // normally the router would populate these params from the path values, - // but because we're calling the function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusUnfavePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) - assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) - assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) - assert.False(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 0, statusReply.FavouritesCount) -} - -// try to unfave a status that's already not faved -func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // this is the status we wanna unfave: in the testrig it's not faved by this account - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - - // normally the router would populate these params from the path values, - // but because we're calling the function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusUnfavePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) - assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) - assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) - assert.False(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 0, statusReply.FavouritesCount) -} - -func TestStatusUnfaveTestSuite(t *testing.T) { - suite.Run(t, new(StatusUnfaveTestSuite)) -} |