diff options
Diffstat (limited to 'internal/apimodule')
38 files changed, 3692 insertions, 185 deletions
diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go index 2d9ddbb72..f4a47f6a2 100644 --- a/internal/apimodule/account/account.go +++ b/internal/apimodule/account/account.go @@ -28,7 +28,9 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "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" @@ -43,21 +45,23 @@ const ( ) type accountModule struct { - config *config.Config - db db.DB - oauthServer oauth.Server - mediaHandler media.MediaHandler - log *logrus.Logger + config *config.Config + db db.DB + oauthServer oauth.Server + mediaHandler media.MediaHandler + 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.MediaHandler, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { return &accountModule{ - config: config, - db: db, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - log: log, + config: config, + db: db, + oauthServer: oauthServer, + mediaHandler: mediaHandler, + mastoConverter: mastoConverter, + log: log, } } @@ -65,19 +69,20 @@ func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler func (m *accountModule) 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 } func (m *accountModule) CreateTables(db db.DB) error { models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, } for _, m := range models { @@ -90,11 +95,16 @@ func (m *accountModule) CreateTables(db db.DB) error { func (m *accountModule) muxHandler(c *gin.Context) { ru := c.Request.RequestURI - if strings.HasPrefix(ru, verifyPath) { - m.accountVerifyGETHandler(c) - } else if strings.HasPrefix(ru, updateCredentialsPath) { - m.accountUpdateCredentialsPATCHHandler(c) - } else { - m.accountGETHandler(c) + 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 index 58b98c0e4..266d820af 100644 --- a/internal/apimodule/account/accountcreate.go +++ b/internal/apimodule/account/accountcreate.go @@ -27,10 +27,10 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "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/gotosocial/pkg/mastotypes" "github.com/superseriousbusiness/oauth2/v4" ) @@ -83,7 +83,7 @@ func (m *accountModule) accountCreatePOSTHandler(c *gin.Context) { // 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 *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *model.Application) (*mastotypes.Token, error) { +func (m *accountModule) 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 diff --git a/internal/apimodule/account/accountcreate_test.go b/internal/apimodule/account/accountcreate_test.go index d14ae3852..8677e3573 100644 --- a/internal/apimodule/account/accountcreate_test.go +++ b/internal/apimodule/account/accountcreate_test.go @@ -41,11 +41,13 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/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/pkg/mastotypes" "github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4/models" oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" @@ -56,12 +58,13 @@ type AccountCreateTestSuite struct { suite.Suite config *config.Config log *logrus.Logger - testAccountLocal *model.Account - testApplication *model.Application + testAccountLocal *gtsmodel.Account + testApplication *gtsmodel.Application testToken oauth2.TokenInfo mockOauthServer *oauth.MockServer mockStorage *storage.MockStorage mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter db db.DB accountModule *accountModule newUserFormHappyPath url.Values @@ -78,13 +81,13 @@ func (suite *AccountCreateTestSuite) SetupSuite() { log.SetLevel(logrus.TraceLevel) suite.log = log - suite.testAccountLocal = &model.Account{ + suite.testAccountLocal = >smodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = >smodel.Application{ ID: "weeweeeeeeeeeeeeee", Name: "a test application", Website: "https://some-application-website.com", @@ -148,7 +151,7 @@ func (suite *AccountCreateTestSuite) SetupSuite() { userID := args.Get(2).(string) l.Infof("received userID %+v", userID) }).Return(&models.Token{ - Code: "we're authorized now!", + Access: "we're authorized now!", }, nil) suite.mockStorage = &storage.MockStorage{} @@ -158,8 +161,10 @@ func (suite *AccountCreateTestSuite) SetupSuite() { // 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 = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) + suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule) } func (suite *AccountCreateTestSuite) TearDownSuite() { @@ -172,14 +177,14 @@ func (suite *AccountCreateTestSuite) TearDownSuite() { func (suite *AccountCreateTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >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 { @@ -210,14 +215,14 @@ func (suite *AccountCreateTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >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 { @@ -259,7 +264,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - t := &mastotypes.Token{} + t := &mastomodel.Token{} err = json.Unmarshal(b, t) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) @@ -267,7 +272,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // check new account // 1. we should be able to get the new account from the db - acct := &model.Account{} + acct := >smodel.Account{} err = suite.db.GetWhere("username", "test_user", acct) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), acct) @@ -288,7 +293,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // check new user // 1. we should be able to get the new user from the db - usr := &model.User{} + usr := >smodel.User{} err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), usr) diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go index 5ee93386d..cd4aed22e 100644 --- a/internal/apimodule/account/accountget.go +++ b/internal/apimodule/account/accountget.go @@ -23,7 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" ) // accountGetHandler serves the account information held by the server in response to a GET @@ -37,7 +37,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) { return } - targetAccount := &model.Account{} + 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"}) @@ -47,7 +47,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) { return } - acctInfo, err := m.db.AccountToMastoPublic(targetAccount) + acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go index 6686d3a50..15e9cf0d1 100644 --- a/internal/apimodule/account/accountupdate.go +++ b/internal/apimodule/account/accountupdate.go @@ -27,10 +27,11 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "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" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" ) // accountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. @@ -67,7 +68,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Discoverable != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &model.Account{}); err != 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 @@ -75,7 +76,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Bot != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &model.Account{}); err != 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 @@ -87,7 +88,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &model.Account{}); err != nil { + 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 } @@ -98,7 +99,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &model.Account{}); err != nil { + 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 @@ -126,7 +127,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Locked != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != 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 } @@ -138,14 +139,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &model.Account{}); err != nil { + 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, &model.Account{}); err != 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 } @@ -156,7 +157,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &model.Account{}); err != nil { + 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 } @@ -168,14 +169,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { // } // fetch the account with all updated values set - updatedAccount := &model.Account{} + 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.db.AccountToMastoSensitive(updatedAccount) + 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()}) @@ -195,7 +196,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { // 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 *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +func (m *accountModule) 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) @@ -217,7 +218,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun } // do the setting - avatarInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "avatar") + avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar) if err != nil { return nil, fmt.Errorf("error processing avatar: %s", err) } @@ -228,7 +229,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun // 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 *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +func (m *accountModule) 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) @@ -250,7 +251,7 @@ func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accoun } // do the setting - headerInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "header") + headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader) if err != nil { return nil, fmt.Errorf("error processing header: %s", err) } diff --git a/internal/apimodule/account/accountupdate_test.go b/internal/apimodule/account/accountupdate_test.go index 651b4d29d..7ca2190d8 100644 --- a/internal/apimodule/account/accountupdate_test.go +++ b/internal/apimodule/account/accountupdate_test.go @@ -39,7 +39,8 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/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" @@ -52,12 +53,13 @@ type AccountUpdateTestSuite struct { suite.Suite config *config.Config log *logrus.Logger - testAccountLocal *model.Account - testApplication *model.Application + testAccountLocal *gtsmodel.Account + testApplication *gtsmodel.Application testToken oauth2.TokenInfo mockOauthServer *oauth.MockServer mockStorage *storage.MockStorage mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter db db.DB accountModule *accountModule newUserFormHappyPath url.Values @@ -74,13 +76,13 @@ func (suite *AccountUpdateTestSuite) SetupSuite() { log.SetLevel(logrus.TraceLevel) suite.log = log - suite.testAccountLocal = &model.Account{ + suite.testAccountLocal = >smodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = >smodel.Application{ ID: "weeweeeeeeeeeeeeee", Name: "a test application", Website: "https://some-application-website.com", @@ -154,8 +156,10 @@ func (suite *AccountUpdateTestSuite) SetupSuite() { // 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 = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) + suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule) } func (suite *AccountUpdateTestSuite) TearDownSuite() { @@ -168,14 +172,14 @@ func (suite *AccountUpdateTestSuite) TearDownSuite() { func (suite *AccountUpdateTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >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 { @@ -206,14 +210,14 @@ func (suite *AccountUpdateTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >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 { diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go index fe8d24b22..584ab6122 100644 --- a/internal/apimodule/account/accountverify.go +++ b/internal/apimodule/account/accountverify.go @@ -38,7 +38,7 @@ func (m *accountModule) accountVerifyGETHandler(c *gin.Context) { } l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) - acctSensitive, err := m.db.AccountToMastoSensitive(authed.Account) + 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()}) diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go new file mode 100644 index 000000000..34a0aa2c8 --- /dev/null +++ b/internal/apimodule/admin/admin.go @@ -0,0 +1,84 @@ +/* + 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 = "/api/v1/admin" + emojiPath = basePath + "/custom_emojis" +) + +type adminModule struct { + config *config.Config + db db.DB + mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { + return &adminModule{ + config: config, + db: db, + mediaHandler: mediaHandler, + mastoConverter: mastoConverter, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *adminModule) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, emojiPath, m.emojiCreatePOSTHandler) + return nil +} + +func (m *adminModule) 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 new file mode 100644 index 000000000..91457c07c --- /dev/null +++ b/internal/apimodule/admin/emojicreate.go @@ -0,0 +1,130 @@ +/* + 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 *adminModule) 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 index 52275c6df..6d7dbdb83 100644 --- a/internal/apimodule/apimodule.go +++ b/internal/apimodule/apimodule.go @@ -25,8 +25,8 @@ import ( ) // ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set -// of functionalities and side effects to a router, by mapping routes and handlers onto it--in other words, a REST API ;) -// A ClientAPIMpdule corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ +// 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 index 534f4cd3e..08292acd1 100644 --- a/internal/apimodule/app/app.go +++ b/internal/apimodule/app/app.go @@ -25,7 +25,8 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "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" ) @@ -33,17 +34,19 @@ import ( const appsPath = "/api/v1/apps" type appModule struct { - server oauth.Server - db db.DB - log *logrus.Logger + 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, log *logrus.Logger) apimodule.ClientAPIModule { +func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { return &appModule{ - server: srv, - db: db, - log: log, + server: srv, + db: db, + mastoConverter: mastoConverter, + log: log, } } @@ -57,9 +60,9 @@ func (m *appModule) CreateTables(db db.DB) error { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go index cd5aff701..ec52a9d37 100644 --- a/internal/apimodule/app/appcreate.go +++ b/internal/apimodule/app/appcreate.go @@ -24,9 +24,9 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "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/pkg/mastotypes" ) // appsPOSTHandler should be served at https://example.org/api/v1/apps @@ -78,7 +78,7 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) { vapidKey := uuid.NewString() // generate the application to put in the database - app := &model.Application{ + app := >smodel.Application{ Name: form.ClientName, Website: form.Website, RedirectURI: form.RedirectURIs, @@ -108,6 +108,12 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) { 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, app.ToMasto()) + c.JSON(http.StatusOK, mastoApp) } diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go index 3a85a4364..b70adeb43 100644 --- a/internal/apimodule/auth/auth.go +++ b/internal/apimodule/auth/auth.go @@ -31,7 +31,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -75,9 +75,9 @@ func (m *authModule) CreateTables(db db.DB) error { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go index 0ec9b4a41..2c272e985 100644 --- a/internal/apimodule/auth/auth_test.go +++ b/internal/apimodule/auth/auth_test.go @@ -22,16 +22,14 @@ import ( "context" "fmt" "testing" - "time" "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/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" "golang.org/x/crypto/bcrypt" ) @@ -39,9 +37,9 @@ type AuthTestSuite struct { suite.Suite oauthServer oauth.Server db db.DB - testAccount *model.Account - testApplication *model.Application - testUser *model.User + testAccount *gtsmodel.Account + testApplication *gtsmodel.Application + testUser *gtsmodel.User testClient *oauth.Client config *config.Config } @@ -75,11 +73,11 @@ func (suite *AuthTestSuite) SetupSuite() { acctID := uuid.NewString() - suite.testAccount = &model.Account{ + suite.testAccount = >smodel.Account{ ID: acctID, Username: "test_user", } - suite.testUser = &model.User{ + suite.testUser = >smodel.User{ EncryptedPassword: string(encryptedPassword), Email: "user@example.org", AccountID: acctID, @@ -89,7 +87,7 @@ func (suite *AuthTestSuite) SetupSuite() { Secret: "some-secret", Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), } - suite.testApplication = &model.Application{ + suite.testApplication = >smodel.Application{ Name: "a test application", Website: "https://some-application-website.com", RedirectURI: "http://localhost:8080", @@ -115,9 +113,9 @@ func (suite *AuthTestSuite) SetupTest() { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { @@ -148,9 +146,9 @@ func (suite *AuthTestSuite) TearDownTest() { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -163,27 +161,6 @@ func (suite *AuthTestSuite) TearDownTest() { suite.db = nil } -func (suite *AuthTestSuite) TestAPIInitialize() { - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - - r, err := router.New(suite.config, log) - if err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - api := New(suite.oauthServer, suite.db, log) - if err := api.Route(r); err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - r.Start() - time.Sleep(60 * time.Second) - if err := r.Stop(context.Background()); err != nil { - suite.FailNow(fmt.Sprintf("error stopping router: %s", err)) - } -} - func TestAuthTestSuite(t *testing.T) { suite.Run(t, new(AuthTestSuite)) } diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go index 4a27cc20e..bf525e09e 100644 --- a/internal/apimodule/auth/authorize.go +++ b/internal/apimodule/auth/authorize.go @@ -27,8 +27,8 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/model" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + "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 @@ -57,7 +57,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) return } - app := &model.Application{ + app := >smodel.Application{ ClientID: clientID, } if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { @@ -66,7 +66,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { } // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 - user := &model.User{ + user := >smodel.User{ ID: userID, } if err := m.db.GetByID(user.ID, user); err != nil { @@ -74,7 +74,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { return } - acct := &model.Account{ + acct := >smodel.Account{ ID: user.AccountID, } diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go index 32fc24d52..4ca1f47a2 100644 --- a/internal/apimodule/auth/middleware.go +++ b/internal/apimodule/auth/middleware.go @@ -20,7 +20,7 @@ package auth import ( "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -46,7 +46,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) // fetch user's and account for this user id - user := &model.User{} + 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 @@ -54,7 +54,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { c.Set(oauth.SessionAuthorizedUser, user) l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) - acct := &model.Account{} + 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 @@ -66,7 +66,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { // check for application token if cid := ti.GetClientID(); cid != "" { l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) - app := &model.Application{} + app := >smodel.Application{} if err := m.db.GetWhere("client_id", cid, app); err != nil { l.Tracef("no app found for client %s", cid) } diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go index 34146cbfc..a6994c90e 100644 --- a/internal/apimodule/auth/signin.go +++ b/internal/apimodule/auth/signin.go @@ -24,7 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "golang.org/x/crypto/bcrypt" ) @@ -84,7 +84,7 @@ func (m *authModule) validatePassword(email string, password string) (userid str } // first we select the user from the database based on email address, bail if no user found for that email - gtsUser := &model.User{} + 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) diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go index bbafff76f..25f3be864 100644 --- a/internal/apimodule/fileserver/fileserver.go +++ b/internal/apimodule/fileserver/fileserver.go @@ -1,20 +1,48 @@ +/* + 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/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/storage" ) -// fileServer implements the RESTAPIModule interface. +const ( + AccountIDKey = "account_id" + MediaTypeKey = "media_type" + MediaSizeKey = "media_size" + FileNameKey = "file_name" + + FilesPath = "files" +) + +// 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 { +type FileServer struct { config *config.Config db db.DB storage storage.Storage @@ -24,34 +52,24 @@ type fileServer struct { // New returns a new fileServer module func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { - - storageBase := config.StorageConfig.BasePath // TODO: do this properly - - return &fileServer{ + return &FileServer{ config: config, db: db, storage: storage, log: log, - storageBase: storageBase, + storageBase: config.StorageConfig.ServeBasePath, } } // Route satisfies the RESTAPIModule interface -func (m *fileServer) Route(s router.Router) error { - // s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) +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 } -func (m *fileServer) CreateTables(db db.DB) error { +func (m *FileServer) CreateTables(db db.DB) error { models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + >smodel.MediaAttachment{}, } for _, m := range models { diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go new file mode 100644 index 000000000..0421c5095 --- /dev/null +++ b/internal/apimodule/fileserver/servefile.go @@ -0,0 +1,243 @@ +/* + 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 new file mode 100644 index 000000000..8af2b40b3 --- /dev/null +++ b/internal/apimodule/fileserver/test/servefile_test.go @@ -0,0 +1,157 @@ +/* + 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.MediaHandler + 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 new file mode 100644 index 000000000..c8d3d7425 --- /dev/null +++ b/internal/apimodule/media/media.go @@ -0,0 +1,73 @@ +/* + 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" +) + +const BasePath = "/api/v1/media" + +type MediaModule struct { + mediaHandler media.MediaHandler + 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.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { + return &MediaModule{ + mediaHandler: mediaHandler, + config: config, + db: db, + mastoConverter: mastoConverter, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *MediaModule) Route(s router.Router) error { + s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) + return nil +} + +func (m *MediaModule) 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 new file mode 100644 index 000000000..06b6d5be6 --- /dev/null +++ b/internal/apimodule/media/mediacreate.go @@ -0,0 +1,192 @@ +/* + 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" +) + +func (m *MediaModule) 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 new file mode 100644 index 000000000..01a0a6a31 --- /dev/null +++ b/internal/apimodule/media/test/mediacreate_test.go @@ -0,0 +1,194 @@ +/* + 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.MediaHandler + 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.MediaModule +} + +/* + 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.MediaModule) +} + +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.PGTokenToOauthToken(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 index 85c7b6ac6..2d4293d0e 100644 --- a/internal/apimodule/mock_ClientAPIModule.go +++ b/internal/apimodule/mock_ClientAPIModule.go @@ -4,6 +4,8 @@ package apimodule import ( mock "github.com/stretchr/testify/mock" + db "github.com/superseriousbusiness/gotosocial/internal/db" + router "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -12,6 +14,20 @@ 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) diff --git a/internal/apimodule/security/flocblock.go b/internal/apimodule/security/flocblock.go new file mode 100644 index 000000000..4bb011d4d --- /dev/null +++ b/internal/apimodule/security/flocblock.go @@ -0,0 +1,27 @@ +/* + 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 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 new file mode 100644 index 000000000..cfac2ce1e --- /dev/null +++ b/internal/apimodule/security/security.go @@ -0,0 +1,50 @@ +/* + 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 apiclient interface +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, + } +} + +func (m *module) Route(s router.Router) error { + s.AttachMiddleware(m.flocBlock) + return nil +} + +func (m *module) CreateTables(db db.DB) error { + return nil +} diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go new file mode 100644 index 000000000..e65293b62 --- /dev/null +++ b/internal/apimodule/status/status.go @@ -0,0 +1,138 @@ +/* + 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 = "id" + BasePath = "/api/v1/statuses" + BasePathWithID = BasePath + "/:" + IDKey + + ContextPath = BasePathWithID + "/context" + + FavouritedPath = BasePathWithID + "/favourited_by" + FavouritePath = BasePathWithID + "/favourite" + UnfavouritePath = BasePathWithID + "/unfavourite" + + RebloggedPath = BasePathWithID + "/reblogged_by" + ReblogPath = BasePathWithID + "/reblog" + UnreblogPath = BasePathWithID + "/unreblog" + + BookmarkPath = BasePathWithID + "/bookmark" + UnbookmarkPath = BasePathWithID + "/unbookmark" + + MutePath = BasePathWithID + "/mute" + UnmutePath = BasePathWithID + "/unmute" + + PinPath = BasePathWithID + "/pin" + UnpinPath = BasePathWithID + "/unpin" +) + +type StatusModule struct { + config *config.Config + db db.DB + mediaHandler media.MediaHandler + 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.MediaHandler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { + return &StatusModule{ + 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 *StatusModule) 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 +} + +func (m *StatusModule) 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 +} + +func (m *StatusModule) 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 new file mode 100644 index 000000000..ce1cc6da7 --- /dev/null +++ b/internal/apimodule/status/statuscreate.go @@ -0,0 +1,463 @@ +/* + 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"` +} + +func (m *StatusModule) 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 *StatusModule) 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) + } else { + 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) + } else { + 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 *StatusModule) 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 *StatusModule) 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 *StatusModule) 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 *StatusModule) 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 new file mode 100644 index 000000000..f67d035d8 --- /dev/null +++ b/internal/apimodule/status/statusdelete.go @@ -0,0 +1,106 @@ +/* + 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" +) + +func (m *StatusModule) 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 new file mode 100644 index 000000000..de475b905 --- /dev/null +++ b/internal/apimodule/status/statusfave.go @@ -0,0 +1,136 @@ +/* + 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" +) + +func (m *StatusModule) 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 new file mode 100644 index 000000000..76a50b2ca --- /dev/null +++ b/internal/apimodule/status/statusfavedby.go @@ -0,0 +1,128 @@ +/* + 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" +) + +func (m *StatusModule) 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 new file mode 100644 index 000000000..ed2e89159 --- /dev/null +++ b/internal/apimodule/status/statusget.go @@ -0,0 +1,111 @@ +/* + 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" +) + +func (m *StatusModule) 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 new file mode 100644 index 000000000..61ffd8e4c --- /dev/null +++ b/internal/apimodule/status/statusunfave.go @@ -0,0 +1,136 @@ +/* + 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" +) + +func (m *StatusModule) 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 new file mode 100644 index 000000000..6c5aa6b7d --- /dev/null +++ b/internal/apimodule/status/test/statuscreate_test.go @@ -0,0 +1,346 @@ +/* + 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.MediaHandler + 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.StatusModule +} + +/* + 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.StatusModule) +} + +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.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", 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.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", 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.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", 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.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", 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.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", 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 new file mode 100644 index 000000000..b15e57e77 --- /dev/null +++ b/internal/apimodule/status/test/statusfave_test.go @@ -0,0 +1,207 @@ +/* + 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.MediaHandler + 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.StatusModule +} + +/* + 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.StatusModule) +} + +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.PGTokenToOauthToken(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.PGTokenToOauthToken(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 new file mode 100644 index 000000000..83f66562b --- /dev/null +++ b/internal/apimodule/status/test/statusfavedby_test.go @@ -0,0 +1,159 @@ +/* + 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.MediaHandler + 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.StatusModule +} + +// 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.StatusModule) +} + +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.PGTokenToOauthToken(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 new file mode 100644 index 000000000..2c2e98acd --- /dev/null +++ b/internal/apimodule/status/test/statusget_test.go @@ -0,0 +1,168 @@ +/* + 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.MediaHandler + 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.StatusModule +} + +/* + 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.StatusModule) +} + +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 new file mode 100644 index 000000000..81276a1ed --- /dev/null +++ b/internal/apimodule/status/test/statusunfave_test.go @@ -0,0 +1,219 @@ +/* + 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.MediaHandler + 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.StatusModule +} + +/* + 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.StatusModule) +} + +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.PGTokenToOauthToken(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.PGTokenToOauthToken(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)) +} |