summaryrefslogtreecommitdiff
path: root/internal/api/client
diff options
context:
space:
mode:
authorLibravatar Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>2021-05-08 14:25:55 +0200
committerLibravatar GitHub <noreply@github.com>2021-05-08 14:25:55 +0200
commit6f5c045284d34ba580d3007f70b97e05d6760527 (patch)
tree7614da22fba906361a918fb3527465b39272ac93 /internal/api/client
parentRevert "make boosts work woo (#12)" (#15) (diff)
downloadgotosocial-6f5c045284d34ba580d3007f70b97e05d6760527.tar.xz
Ap (#14)
Big restructuring and initial work on activitypub
Diffstat (limited to 'internal/api/client')
-rw-r--r--internal/api/client/account/account.go85
-rw-r--r--internal/api/client/account/account_test.go40
-rw-r--r--internal/api/client/account/accountcreate.go113
-rw-r--r--internal/api/client/account/accountcreate_test.go388
-rw-r--r--internal/api/client/account/accountget.go52
-rw-r--r--internal/api/client/account/accountupdate.go71
-rw-r--r--internal/api/client/account/accountupdate_test.go106
-rw-r--r--internal/api/client/account/accountverify.go48
-rw-r--r--internal/api/client/account/accountverify_test.go19
-rw-r--r--internal/api/client/admin/admin.go58
-rw-r--r--internal/api/client/admin/emojicreate.go94
-rw-r--r--internal/api/client/app/app.go54
-rw-r--r--internal/api/client/app/app_test.go21
-rw-r--r--internal/api/client/app/appcreate.go79
-rw-r--r--internal/api/client/auth/auth.go71
-rw-r--r--internal/api/client/auth/auth_test.go166
-rw-r--r--internal/api/client/auth/authorize.go204
-rw-r--r--internal/api/client/auth/middleware.go76
-rw-r--r--internal/api/client/auth/signin.go116
-rw-r--r--internal/api/client/auth/token.go36
-rw-r--r--internal/api/client/fileserver/fileserver.go82
-rw-r--r--internal/api/client/fileserver/servefile.go94
-rw-r--r--internal/api/client/fileserver/servefile_test.go163
-rw-r--r--internal/api/client/media/media.go71
-rw-r--r--internal/api/client/media/mediacreate.go91
-rw-r--r--internal/api/client/media/mediacreate_test.go200
-rw-r--r--internal/api/client/status/status.go118
-rw-r--r--internal/api/client/status/status_test.go58
-rw-r--r--internal/api/client/status/statuscreate.go130
-rw-r--r--internal/api/client/status/statuscreate_test.go297
-rw-r--r--internal/api/client/status/statusdelete.go60
-rw-r--r--internal/api/client/status/statusfave.go60
-rw-r--r--internal/api/client/status/statusfave_test.go158
-rw-r--r--internal/api/client/status/statusfavedby.go60
-rw-r--r--internal/api/client/status/statusfavedby_test.go114
-rw-r--r--internal/api/client/status/statusget.go60
-rw-r--r--internal/api/client/status/statusget_test.go117
-rw-r--r--internal/api/client/status/statusunfave.go60
-rw-r--r--internal/api/client/status/statusunfave_test.go170
39 files changed, 4060 insertions, 0 deletions
diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go
new file mode 100644
index 000000000..dce810202
--- /dev/null
+++ b/internal/api/client/account/account.go
@@ -0,0 +1,85 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // IDKey is the key to use for retrieving account ID in requests
+ IDKey = "id"
+ // BasePath is the base API path for this module
+ BasePath = "/api/v1/accounts"
+ // BasePathWithID is the base path for this module with the ID key
+ BasePathWithID = BasePath + "/:" + IDKey
+ // VerifyPath is for verifying account credentials
+ VerifyPath = BasePath + "/verify_credentials"
+ // UpdateCredentialsPath is for updating account credentials
+ UpdateCredentialsPath = BasePath + "/update_credentials"
+)
+
+// Module implements the ClientAPIModule interface for account-related actions
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler)
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+func (m *Module) muxHandler(c *gin.Context) {
+ ru := c.Request.RequestURI
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, VerifyPath) {
+ m.AccountVerifyGETHandler(c)
+ } else {
+ m.AccountGETHandler(c)
+ }
+ case http.MethodPatch:
+ if strings.HasPrefix(ru, UpdateCredentialsPath) {
+ m.AccountUpdateCredentialsPATCHHandler(c)
+ }
+ }
+}
diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go
new file mode 100644
index 000000000..d0560bcb6
--- /dev/null
+++ b/internal/api/client/account/account_test.go
@@ -0,0 +1,40 @@
+package account_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type AccountStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ storage storage.Storage
+ federator federation.Federator
+ processor message.Processor
+
+ // 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
+ accountModule *account.Module
+}
diff --git a/internal/api/client/account/accountcreate.go b/internal/api/client/account/accountcreate.go
new file mode 100644
index 000000000..b53d8c412
--- /dev/null
+++ b/internal/api/client/account/accountcreate.go
@@ -0,0 +1,113 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "errors"
+ "net"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// AccountCreatePOSTHandler handles create account requests, validates them,
+// and puts them in the database if they're valid.
+// It should be served as a POST at /api/v1/accounts
+func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, false, false)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Trace("parsing request form")
+ form := &model.AccountCreateRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateAccount(form, m.config.AccountsConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ clientIP := c.ClientIP()
+ l.Tracef("attempting to parse client ip address %s", clientIP)
+ signUpIP := net.ParseIP(clientIP)
+ if signUpIP == nil {
+ l.Debugf("error validating sign up ip address %s", clientIP)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"})
+ return
+ }
+
+ form.IP = signUpIP
+
+ ti, err := m.processor.AccountCreate(authed, form)
+ if err != nil {
+ l.Errorf("internal server error while creating new account: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, ti)
+}
+
+// validateCreateAccount checks through all the necessary prerequisites for creating a new account,
+// according to the provided account create request. If the account isn't eligible, an error will be returned.
+func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error {
+ if !c.OpenRegistration {
+ return errors.New("registration is not open for this server")
+ }
+
+ if err := util.ValidateUsername(form.Username); err != nil {
+ return err
+ }
+
+ if err := util.ValidateEmail(form.Email); err != nil {
+ return err
+ }
+
+ if err := util.ValidateNewPassword(form.Password); err != nil {
+ return err
+ }
+
+ if !form.Agreement {
+ return errors.New("agreement to terms and conditions not given")
+ }
+
+ if err := util.ValidateLanguage(form.Locale); err != nil {
+ return err
+ }
+
+ if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go
new file mode 100644
index 000000000..da86ee940
--- /dev/null
+++ b/internal/api/client/account/accountcreate_test.go
@@ -0,0 +1,388 @@
+// /*
+// GoToSocial
+// Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+// */
+
+package account_test
+
+// import (
+// "bytes"
+// "encoding/json"
+// "fmt"
+// "io"
+// "io/ioutil"
+// "mime/multipart"
+// "net/http"
+// "net/http/httptest"
+// "os"
+// "testing"
+
+// "github.com/gin-gonic/gin"
+// "github.com/google/uuid"
+// "github.com/stretchr/testify/assert"
+// "github.com/stretchr/testify/suite"
+// "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+// "github.com/superseriousbusiness/gotosocial/internal/api/model"
+// "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+// "github.com/superseriousbusiness/gotosocial/testrig"
+
+// "github.com/superseriousbusiness/gotosocial/internal/oauth"
+// "golang.org/x/crypto/bcrypt"
+// )
+
+// type AccountCreateTestSuite struct {
+// AccountStandardTestSuite
+// }
+
+// func (suite *AccountCreateTestSuite) SetupSuite() {
+// suite.testTokens = testrig.NewTestTokens()
+// suite.testClients = testrig.NewTestClients()
+// suite.testApplications = testrig.NewTestApplications()
+// suite.testUsers = testrig.NewTestUsers()
+// suite.testAccounts = testrig.NewTestAccounts()
+// suite.testAttachments = testrig.NewTestAttachments()
+// suite.testStatuses = testrig.NewTestStatuses()
+// }
+
+// func (suite *AccountCreateTestSuite) SetupTest() {
+// suite.config = testrig.NewTestConfig()
+// suite.db = testrig.NewTestDB()
+// suite.storage = testrig.NewTestStorage()
+// suite.log = testrig.NewTestLog()
+// suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+// suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+// suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+// testrig.StandardDBSetup(suite.db)
+// testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+// }
+
+// func (suite *AccountCreateTestSuite) TearDownTest() {
+// testrig.StandardDBTeardown(suite.db)
+// testrig.StandardStorageTeardown(suite.storage)
+// }
+
+// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
+// // and at the end of it a new user and account should be added into the database.
+// //
+// // This is the handler served at /api/v1/accounts as POST
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
+
+// t := suite.testTokens["local_account_1"]
+// oauthToken := oauth.TokenToOauthToken(t)
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+// ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+
+// // 1. we should have OK from our call to the function
+// suite.EqualValues(http.StatusOK, recorder.Code)
+
+// // 2. we should have a token in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// t := &model.Token{}
+// err = json.Unmarshal(b, t)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
+
+// // check new account
+
+// // 1. we should be able to get the new account from the db
+// acct := &gtsmodel.Account{}
+// err = suite.db.GetLocalAccountByUsername("test_user", acct)
+// assert.NoError(suite.T(), err)
+// assert.NotNil(suite.T(), acct)
+// // 2. reason should be set
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
+// // 3. display name should be equal to username by default
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
+// // 4. domain should be nil because this is a local account
+// assert.Nil(suite.T(), nil, acct.Domain)
+// // 5. id should be set and parseable as a uuid
+// assert.NotNil(suite.T(), acct.ID)
+// _, err = uuid.Parse(acct.ID)
+// assert.Nil(suite.T(), err)
+// // 6. private and public key should be set
+// assert.NotNil(suite.T(), acct.PrivateKey)
+// assert.NotNil(suite.T(), acct.PublicKey)
+
+// // check new user
+
+// // 1. we should be able to get the new user from the db
+// usr := &gtsmodel.User{}
+// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
+// assert.Nil(suite.T(), err)
+// assert.NotNil(suite.T(), usr)
+
+// // 2. user should have account id set to account we got above
+// assert.Equal(suite.T(), acct.ID, usr.AccountID)
+
+// // 3. id should be set and parseable as a uuid
+// assert.NotNil(suite.T(), usr.ID)
+// _, err = uuid.Parse(usr.ID)
+// assert.Nil(suite.T(), err)
+
+// // 4. locale should be equal to what we requested
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
+
+// // 5. created by application id should be equal to the app id
+// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
+
+// // 6. password should be matcheable to what we set above
+// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
+// assert.Nil(suite.T(), err)
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
+// // only registered applications can create accounts, and we don't provide one here.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+
+// // 1. we should have forbidden from our call to the function because we didn't auth
+// suite.EqualValues(http.StatusForbidden, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// // set a weak password
+// ctx.Request.Form.Set("password", "weak")
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// // set an invalid locale
+// ctx.Request.Form.Set("locale", "neverneverland")
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // close registrations
+// suite.config.AccountsConfig.OpenRegistration = false
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // remove reason
+// ctx.Request.Form.Set("reason", "")
+
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // remove reason
+// ctx.Request.Form.Set("reason", "just cuz")
+
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
+// }
+
+// /*
+// TESTING: AccountUpdateCredentialsPATCHHandler
+// */
+
+// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+// // put test local account in db
+// err := suite.db.Put(suite.testAccountLocal)
+// assert.NoError(suite.T(), err)
+
+// // attach avatar to request
+// aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
+// assert.NoError(suite.T(), err)
+// body := &bytes.Buffer{}
+// writer := multipart.NewWriter(body)
+
+// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
+// assert.NoError(suite.T(), err)
+
+// _, err = io.Copy(part, aviFile)
+// assert.NoError(suite.T(), err)
+
+// err = aviFile.Close()
+// assert.NoError(suite.T(), err)
+
+// err = writer.Close()
+// assert.NoError(suite.T(), err)
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
+// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
+// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+// // check response
+
+// // 1. we should have OK because our request was valid
+// suite.EqualValues(http.StatusOK, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// // TODO: implement proper checks here
+// //
+// // b, err := ioutil.ReadAll(result.Body)
+// // assert.NoError(suite.T(), err)
+// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// func TestAccountCreateTestSuite(t *testing.T) {
+// suite.Run(t, new(AccountCreateTestSuite))
+// }
diff --git a/internal/api/client/account/accountget.go b/internal/api/client/account/accountget.go
new file mode 100644
index 000000000..5ca17a167
--- /dev/null
+++ b/internal/api/client/account/accountget.go
@@ -0,0 +1,52 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountGETHandler serves the account information held by the server in response to a GET
+// request. It should be served as a GET at /api/v1/accounts/:id.
+//
+// See: https://docs.joinmastodon.org/methods/accounts/
+func (m *Module) AccountGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ return
+ }
+
+ targetAcctID := c.Param(IDKey)
+ if targetAcctID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})
+ return
+ }
+
+ acctInfo, err := m.processor.AccountGet(authed, targetAcctID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, acctInfo)
+}
diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go
new file mode 100644
index 000000000..406769fe7
--- /dev/null
+++ b/internal/api/client/account/accountupdate.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
+// It should be served as a PATCH at /api/v1/accounts/update_credentials
+//
+// TODO: this can be optimized massively by building up a picture of what we want the new account
+// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
+// which is not gonna make the database very happy when lots of requests are going through.
+// This way it would also be safer because the update won't happen until *all* the fields are validated.
+// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
+func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
+ authed, err := oauth.Authed(c, true, false, false, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ l.Tracef("retrieved account %+v", authed.Account.ID)
+
+ l.Trace("parsing request form")
+ form := &model.UpdateCredentialsRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // if everything on the form is nil, then nothing has been set and we shouldn't continue
+ if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
+ l.Debugf("could not parse form from request")
+ c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
+ return
+ }
+
+ acctSensitive, err := m.processor.AccountUpdate(authed, form)
+ if err != nil {
+ l.Debugf("could not update account: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
+ c.JSON(http.StatusOK, acctSensitive)
+}
diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go
new file mode 100644
index 000000000..ba7faa794
--- /dev/null
+++ b/internal/api/client/account/accountupdate_test.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 account_test
+
+import (
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type AccountUpdateTestSuite struct {
+ AccountStandardTestSuite
+}
+
+func (suite *AccountUpdateTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *AccountUpdateTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *AccountUpdateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+ requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+ "display_name": "updated zork display name!!!",
+ "locked": "true",
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"]))
+ ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting
+ ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+ suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK because our request was valid
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ // 2. we should have no error message in the result body
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ // TODO write more assertions allee
+}
+
+func TestAccountUpdateTestSuite(t *testing.T) {
+ suite.Run(t, new(AccountUpdateTestSuite))
+}
diff --git a/internal/api/client/account/accountverify.go b/internal/api/client/account/accountverify.go
new file mode 100644
index 000000000..4c62ff705
--- /dev/null
+++ b/internal/api/client/account/accountverify.go
@@ -0,0 +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 account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountVerifyGETHandler serves a user's account details to them IF they reached this
+// handler while in possession of a valid token, according to the oauth middleware.
+// It should be served as a GET at /api/v1/accounts/verify_credentials
+func (m *Module) AccountVerifyGETHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountVerifyGETHandler")
+ authed, err := oauth.Authed(c, true, false, false, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID)
+ if err != nil {
+ l.Debugf("error getting account from processor: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
+ return
+ }
+
+ c.JSON(http.StatusOK, acctSensitive)
+}
diff --git a/internal/api/client/account/accountverify_test.go b/internal/api/client/account/accountverify_test.go
new file mode 100644
index 000000000..85b0dce50
--- /dev/null
+++ b/internal/api/client/account/accountverify_test.go
@@ -0,0 +1,19 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account_test
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go
new file mode 100644
index 000000000..7ce5311eb
--- /dev/null
+++ b/internal/api/client/admin/admin.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // BasePath is the base API path for this module
+ BasePath = "/api/v1/admin"
+ // EmojiPath is used for posting/deleting custom emojis
+ EmojiPath = BasePath + "/custom_emojis"
+)
+
+// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new admin module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)
+ return nil
+}
diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go
new file mode 100644
index 000000000..0e60db65f
--- /dev/null
+++ b/internal/api/client/admin/emojicreate.go
@@ -0,0 +1,94 @@
+/*
+ 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 (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "emojiCreatePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+
+ // make sure we're authed with an admin account
+ authed, err := oauth.Authed(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 := &model.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
+ }
+
+ mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form)
+ if err != nil {
+ l.Debugf("error creating emoji: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoEmoji)
+}
+
+func validateCreateEmoji(form *model.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/api/client/app/app.go b/internal/api/client/app/app.go
new file mode 100644
index 000000000..d1e732a8c
--- /dev/null
+++ b/internal/api/client/app/app.go
@@ -0,0 +1,54 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// BasePath is the base path for this api module
+const BasePath = "/api/v1/apps"
+
+// Module implements the ClientAPIModule interface for requests relating to registering/removing applications
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
+ return nil
+}
diff --git a/internal/api/client/app/app_test.go b/internal/api/client/app/app_test.go
new file mode 100644
index 000000000..42760a2db
--- /dev/null
+++ b/internal/api/client/app/app_test.go
@@ -0,0 +1,21 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app_test
+
+// TODO: write tests
diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go
new file mode 100644
index 000000000..fd42482d4
--- /dev/null
+++ b/internal/api/client/app/appcreate.go
@@ -0,0 +1,79 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AppsPOSTHandler should be served at https://example.org/api/v1/apps
+// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
+func (m *Module) AppsPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AppsPOSTHandler")
+ l.Trace("entering AppsPOSTHandler")
+
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
+ return
+ }
+
+ form := &model.ApplicationCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
+ return
+ }
+
+ // permitted length for most fields
+ formFieldLen := 64
+ // redirect can be a bit bigger because we probably need to encode data in the redirect uri
+ formRedirectLen := 512
+
+ // check lengths of fields before proceeding so the user can't spam huge entries into the database
+ if len(form.ClientName) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)})
+ return
+ }
+ if len(form.Website) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)})
+ return
+ }
+ if len(form.RedirectURIs) > formRedirectLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)})
+ return
+ }
+ if len(form.Scopes) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)})
+ return
+ }
+
+ mastoApp, err := m.processor.AppCreate(authed, form)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
+ c.JSON(http.StatusOK, mastoApp)
+}
diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go
new file mode 100644
index 000000000..793c19f4e
--- /dev/null
+++ b/internal/api/client/auth/auth.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // AuthSignInPath is the API path for users to sign in through
+ AuthSignInPath = "/auth/sign_in"
+ // OauthTokenPath is the API path to use for granting token requests to users with valid credentials
+ OauthTokenPath = "/oauth/token"
+ // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
+ OauthAuthorizePath = "/oauth/authorize"
+)
+
+// Module implements the ClientAPIModule interface for
+type Module struct {
+ config *config.Config
+ db db.DB
+ server oauth.Server
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ db: db,
+ server: server,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler)
+ s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler)
+
+ s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
+
+ s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
+ s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
+
+ s.AttachMiddleware(m.OauthTokenMiddleware)
+ return nil
+}
diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go
new file mode 100644
index 000000000..7ec788a0e
--- /dev/null
+++ b/internal/api/client/auth/auth_test.go
@@ -0,0 +1,166 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type AuthTestSuite struct {
+ suite.Suite
+ oauthServer oauth.Server
+ db db.DB
+ testAccount *gtsmodel.Account
+ testApplication *gtsmodel.Application
+ testUser *gtsmodel.User
+ testClient *oauth.Client
+ config *config.Config
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *AuthTestSuite) SetupSuite() {
+ c := config.Empty()
+ // we're running on localhost without https so set the protocol to http
+ c.Protocol = "http"
+ // just for testing
+ c.Host = "localhost:8080"
+ // because go tests are run within the test package directory, we need to fiddle with the templateconfig
+ // basedir in a way that we wouldn't normally have to do when running the binary, in order to make
+ // the templates actually load
+ c.TemplateConfig.BaseDir = "../../../web/template/"
+ c.DBConfig = &config.DBConfig{
+ Type: "postgres",
+ Address: "localhost",
+ Port: 5432,
+ User: "postgres",
+ Password: "postgres",
+ Database: "postgres",
+ ApplicationName: "gotosocial",
+ }
+ suite.config = c
+
+ encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
+ if err != nil {
+ logrus.Panicf("error encrypting user pass: %s", err)
+ }
+
+ acctID := uuid.NewString()
+
+ suite.testAccount = &gtsmodel.Account{
+ ID: acctID,
+ Username: "test_user",
+ }
+ suite.testUser = &gtsmodel.User{
+ EncryptedPassword: string(encryptedPassword),
+ Email: "user@example.org",
+ AccountID: acctID,
+ }
+ suite.testClient = &oauth.Client{
+ ID: "a-known-client-id",
+ Secret: "some-secret",
+ Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
+ }
+ suite.testApplication = &gtsmodel.Application{
+ Name: "a test application",
+ Website: "https://some-application-website.com",
+ RedirectURI: "http://localhost:8080",
+ ClientID: "a-known-client-id",
+ ClientSecret: "some-secret",
+ Scopes: "read",
+ VapidKey: uuid.NewString(),
+ }
+}
+
+// SetupTest creates a postgres connection and creates the oauth_clients table before each test
+func (suite *AuthTestSuite) SetupTest() {
+
+ log := logrus.New()
+ log.SetLevel(logrus.TraceLevel)
+ db, err := db.NewPostgresService(context.Background(), suite.config, log)
+ if err != nil {
+ logrus.Panicf("error creating database connection: %s", err)
+ }
+
+ suite.db = db
+
+ models := []interface{}{
+ &oauth.Client{},
+ &oauth.Token{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
+ }
+
+ for _, m := range models {
+ if err := suite.db.CreateTable(m); err != nil {
+ logrus.Panicf("db connection error: %s", err)
+ }
+ }
+
+ suite.oauthServer = oauth.New(suite.db, log)
+
+ if err := suite.db.Put(suite.testAccount); err != nil {
+ logrus.Panicf("could not insert test account into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testUser); err != nil {
+ logrus.Panicf("could not insert test user into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testClient); err != nil {
+ logrus.Panicf("could not insert test client into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testApplication); err != nil {
+ logrus.Panicf("could not insert test application into db: %s", err)
+ }
+
+}
+
+// TearDownTest drops the oauth_clients table and closes the pg connection after each test
+func (suite *AuthTestSuite) TearDownTest() {
+ models := []interface{}{
+ &oauth.Client{},
+ &oauth.Token{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
+ }
+ for _, m := range models {
+ if err := suite.db.DropTable(m); err != nil {
+ logrus.Panicf("error dropping table: %s", err)
+ }
+ }
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+ suite.db = nil
+}
+
+func TestAuthTestSuite(t *testing.T) {
+ suite.Run(t, new(AuthTestSuite))
+}
diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go
new file mode 100644
index 000000000..d5f8ee214
--- /dev/null
+++ b/internal/api/client/auth/authorize.go
@@ -0,0 +1,204 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
+// The idea here is to present an oauth authorize page to the user, with a button
+// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
+func (m *Module) AuthorizeGETHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AuthorizeGETHandler")
+ s := sessions.Default(c)
+
+ // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow
+ // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page.
+ userID, ok := s.Get("userid").(string)
+ if !ok || userID == "" {
+ l.Trace("userid was empty, parsing form then redirecting to sign in page")
+ if err := parseAuthForm(c, l); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ } else {
+ c.Redirect(http.StatusFound, AuthSignInPath)
+ }
+ return
+ }
+
+ // We can use the client_id on the session to retrieve info about the app associated with the client_id
+ clientID, ok := s.Get("client_id").(string)
+ if !ok || clientID == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"})
+ return
+ }
+ app := &gtsmodel.Application{
+ ClientID: clientID,
+ }
+ if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
+ return
+ }
+
+ // we can also use the userid of the user to fetch their username from the db to greet them nicely <3
+ user := &gtsmodel.User{
+ ID: userID,
+ }
+ if err := m.db.GetByID(user.ID, user); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ acct := &gtsmodel.Account{
+ ID: user.AccountID,
+ }
+
+ if err := m.db.GetByID(acct.ID, acct); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Finally we should also get the redirect and scope of this particular request, as stored in the session.
+ redirect, ok := s.Get("redirect_uri").(string)
+ if !ok || redirect == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"})
+ return
+ }
+ scope, ok := s.Get("scope").(string)
+ if !ok || scope == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"})
+ return
+ }
+
+ // the authorize template will display a form to the user where they can get some information
+ // about the app that's trying to authorize, and the scope of the request.
+ // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler
+ l.Trace("serving authorize html")
+ c.HTML(http.StatusOK, "authorize.tmpl", gin.H{
+ "appname": app.Name,
+ "appwebsite": app.Website,
+ "redirect": redirect,
+ "scope": scope,
+ "user": acct.Username,
+ })
+}
+
+// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
+// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
+// so we should proceed with the authentication flow and generate an oauth token for them if we can.
+// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
+func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AuthorizePOSTHandler")
+ s := sessions.Default(c)
+
+ // At this point we know the user has said 'yes' to allowing the application and oauth client
+ // work for them, so we can set the
+
+ // We need to retrieve the original form submitted to the authorizeGEThandler, and
+ // recreate it on the request so that it can be used further by the oauth2 library.
+ // So first fetch all the values from the session.
+ forceLogin, ok := s.Get("force_login").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"})
+ return
+ }
+ responseType, ok := s.Get("response_type").(string)
+ if !ok || responseType == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"})
+ return
+ }
+ clientID, ok := s.Get("client_id").(string)
+ if !ok || clientID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"})
+ return
+ }
+ redirectURI, ok := s.Get("redirect_uri").(string)
+ if !ok || redirectURI == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"})
+ return
+ }
+ scope, ok := s.Get("scope").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"})
+ return
+ }
+ userID, ok := s.Get("userid").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"})
+ return
+ }
+ // we're done with the session so we can clear it now
+ s.Clear()
+
+ // now set the values on the request
+ values := url.Values{}
+ values.Set("force_login", forceLogin)
+ values.Set("response_type", responseType)
+ values.Set("client_id", clientID)
+ values.Set("redirect_uri", redirectURI)
+ values.Set("scope", scope)
+ values.Set("userid", userID)
+ c.Request.Form = values
+ l.Tracef("values on request set to %+v", c.Request.Form)
+
+ // and proceed with authorization using the oauth2 library
+ if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ }
+}
+
+// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores
+// the values in the form into the session.
+func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
+ s := sessions.Default(c)
+
+ // first make sure they've filled out the authorize form with the required values
+ form := &model.OAuthAuthorize{}
+ if err := c.ShouldBind(form); err != nil {
+ return err
+ }
+ l.Tracef("parsed form: %+v", form)
+
+ // these fields are *required* so check 'em
+ if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
+ return errors.New("missing one of: response_type, client_id or redirect_uri")
+ }
+
+ // set default scope to read
+ if form.Scope == "" {
+ form.Scope = "read"
+ }
+
+ // save these values from the form so we can use them elsewhere in the session
+ s.Set("force_login", form.ForceLogin)
+ s.Set("response_type", form.ResponseType)
+ s.Set("client_id", form.ClientID)
+ s.Set("redirect_uri", form.RedirectURI)
+ s.Set("scope", form.Scope)
+ return s.Save()
+}
diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.go
new file mode 100644
index 000000000..c42ba77fc
--- /dev/null
+++ b/internal/api/client/auth/middleware.go
@@ -0,0 +1,76 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token.
+// If so, it will check the User that the token belongs to, and set that in the context of
+// the request. Then, it will look up the account for that user, and set that in the request too.
+// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow
+// public requests that don't have a Bearer token set (eg., for public instance information and so on).
+func (m *Module) OauthTokenMiddleware(c *gin.Context) {
+ l := m.log.WithField("func", "OauthTokenMiddleware")
+ l.Trace("entering OauthTokenMiddleware")
+
+ ti, err := m.server.ValidationBearerToken(c.Request)
+ if err != nil {
+ l.Trace("no valid token presented: continuing with unauthenticated request")
+ return
+ }
+ c.Set(oauth.SessionAuthorizedToken, ti)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti)
+
+ // check for user-level token
+ if uid := ti.GetUserID(); uid != "" {
+ l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope())
+
+ // fetch user's and account for this user id
+ user := &gtsmodel.User{}
+ if err := m.db.GetByID(uid, user); err != nil || user == nil {
+ l.Warnf("no user found for validated uid %s", uid)
+ return
+ }
+ c.Set(oauth.SessionAuthorizedUser, user)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
+
+ acct := &gtsmodel.Account{}
+ if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
+ l.Warnf("no account found for validated user %s", uid)
+ return
+ }
+ c.Set(oauth.SessionAuthorizedAccount, acct)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct)
+ }
+
+ // check for application token
+ if cid := ti.GetClientID(); cid != "" {
+ l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
+ app := &gtsmodel.Application{}
+ if err := m.db.GetWhere("client_id", cid, app); err != nil {
+ l.Tracef("no app found for client %s", cid)
+ }
+ c.Set(oauth.SessionAuthorizedApplication, app)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
+ }
+}
diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go
new file mode 100644
index 000000000..79d9b300e
--- /dev/null
+++ b/internal/api/client/auth/signin.go
@@ -0,0 +1,116 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// login just wraps a form-submitted username (we want an email) and password
+type login struct {
+ Email string `form:"username"`
+ Password string `form:"password"`
+}
+
+// SignInGETHandler should be served at https://example.org/auth/sign_in.
+// The idea is to present a sign in page to the user, where they can enter their username and password.
+// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler
+func (m *Module) SignInGETHandler(c *gin.Context) {
+ m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html")
+ c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{})
+}
+
+// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
+// The idea is to present a sign in page to the user, where they can enter their username and password.
+// The handler will then redirect to the auth handler served at /auth
+func (m *Module) SignInPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "SignInPOSTHandler")
+ s := sessions.Default(c)
+ form := &login{}
+ if err := c.ShouldBind(form); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ l.Tracef("parsed form: %+v", form)
+
+ userid, err := m.ValidatePassword(form.Email, form.Password)
+ if err != nil {
+ c.String(http.StatusForbidden, err.Error())
+ return
+ }
+
+ s.Set("userid", userid)
+ if err := s.Save(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Trace("redirecting to auth page")
+ c.Redirect(http.StatusFound, OauthAuthorizePath)
+}
+
+// ValidatePassword takes an email address and a password.
+// The goal is to authenticate the password against the one for that email
+// address stored in the database. If OK, we return the userid (a uuid) for that user,
+// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
+func (m *Module) ValidatePassword(email string, password string) (userid string, err error) {
+ l := m.log.WithField("func", "ValidatePassword")
+
+ // make sure an email/password was provided and bail if not
+ if email == "" || password == "" {
+ l.Debug("email or password was not provided")
+ return incorrectPassword()
+ }
+
+ // first we select the user from the database based on email address, bail if no user found for that email
+ gtsUser := &gtsmodel.User{}
+
+ if err := m.db.GetWhere("email", email, gtsUser); err != nil {
+ l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
+ return incorrectPassword()
+ }
+
+ // make sure a password is actually set and bail if not
+ if gtsUser.EncryptedPassword == "" {
+ l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email)
+ return incorrectPassword()
+ }
+
+ // compare the provided password with the encrypted one from the db, bail if they don't match
+ if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil {
+ l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err)
+ return incorrectPassword()
+ }
+
+ // If we've made it this far the email/password is correct, so we can just return the id of the user.
+ userid = gtsUser.ID
+ l.Tracef("returning (%s, %s)", userid, err)
+ return
+}
+
+// incorrectPassword is just a little helper function to use in the ValidatePassword function
+func incorrectPassword() (string, error) {
+ return "", errors.New("password/email combination was incorrect")
+}
diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go
new file mode 100644
index 000000000..c531a3009
--- /dev/null
+++ b/internal/api/client/auth/token.go
@@ -0,0 +1,36 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token
+// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs.
+// See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token
+func (m *Module) TokenPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "TokenPOSTHandler")
+ l.Trace("entered TokenPOSTHandler")
+ if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ }
+}
diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go
new file mode 100644
index 000000000..63d323a01
--- /dev/null
+++ b/internal/api/client/fileserver/fileserver.go
@@ -0,0 +1,82 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package fileserver
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // AccountIDKey is the url key for account id (an account uuid)
+ AccountIDKey = "account_id"
+ // MediaTypeKey is the url key for media type (usually something like attachment or header etc)
+ MediaTypeKey = "media_type"
+ // MediaSizeKey is the url key for the desired media size--original/small/static
+ MediaSizeKey = "media_size"
+ // FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg
+ FileNameKey = "file_name"
+)
+
+// FileServer implements the RESTAPIModule interface.
+// The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
+type FileServer struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+ storageBase string
+}
+
+// New returns a new fileServer module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &FileServer{
+ config: config,
+ processor: processor,
+ log: log,
+ storageBase: config.StorageConfig.ServeBasePath,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *FileServer) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile)
+ return nil
+}
+
+// CreateTables populates necessary tables in the given DB
+func (m *FileServer) CreateTables(db db.DB) error {
+ models := []interface{}{
+ &gtsmodel.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/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go
new file mode 100644
index 000000000..9823eb387
--- /dev/null
+++ b/internal/api/client/fileserver/servefile.go
@@ -0,0 +1,94 @@
+/*
+ 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"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// 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")
+
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // 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
+ }
+
+ content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{
+ AccountID: accountID,
+ MediaType: mediaType,
+ MediaSize: mediaSize,
+ FileName: fileName,
+ })
+ if err != nil {
+ l.Debug(err)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil)
+}
diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go
new file mode 100644
index 000000000..09fd8ea43
--- /dev/null
+++ b/internal/api/client/fileserver/servefile_test.go
@@ -0,0 +1,163 @@
+/*
+ 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_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/api/client/fileserver"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "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
+ federator federation.Federator
+ tc typeutils.TypeConverter
+ processor message.Processor
+ mediaHandler media.Handler
+ oauthServer oauth.Server
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ fileServer *fileserver.FileServer
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *ServeFileTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.tc = testrig.NewTestTypeConverter(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.processor, 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: string(media.Attachment),
+ },
+ gin.Param{
+ Key: fileserver.MediaSizeKey,
+ Value: string(media.Original),
+ },
+ 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/api/client/media/media.go b/internal/api/client/media/media.go
new file mode 100644
index 000000000..2826783d6
--- /dev/null
+++ b/internal/api/client/media/media.go
@@ -0,0 +1,71 @@
+/*
+ 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/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// BasePath is the base API path for making media requests
+const BasePath = "/api/v1/media"
+
+// Module implements the ClientAPIModule interface for media
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler)
+ return nil
+}
+
+// CreateTables populates necessary tables in the given DB
+func (m *Module) CreateTables(db db.DB) error {
+ models := []interface{}{
+ &gtsmodel.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/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go
new file mode 100644
index 000000000..db57e2052
--- /dev/null
+++ b/internal/api/client/media/mediacreate.go
@@ -0,0 +1,91 @@
+/*
+ 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 (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// MediaCreatePOSTHandler handles requests to create/upload media attachments
+func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(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
+ }
+
+ // extract the media create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &model.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
+ }
+
+ mastoAttachment, err := m.processor.MediaCreate(authed, form)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusAccepted, mastoAttachment)
+}
+
+func validateCreateMedia(form *model.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/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
new file mode 100644
index 000000000..e86c66021
--- /dev/null
+++ b/internal/api/client/media/mediacreate_test.go
@@ -0,0 +1,200 @@
+/*
+ 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_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/api/client/media"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "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
+ federator federation.Federator
+ tc typeutils.TypeConverter
+ mediaHandler media.Handler
+ oauthServer oauth.Server
+ processor message.Processor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ mediaModule *mediamodule.Module
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *MediaCreateTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.tc = testrig.NewTestTypeConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+
+ // setup module being tested
+ suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module)
+}
+
+func (suite *MediaCreateTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+func (suite *MediaCreateTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+func (suite *MediaCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
+
+ // set up the context for the request
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+
+ // see what's in storage *before* the request
+ storageKeysBeforeRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // create the request
+ buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+ "description": "this is a test image -- a cool background from somewhere",
+ "focus": "-0.5,0.5",
+ })
+ if err != nil {
+ panic(err)
+ }
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
+ ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+
+ // do the actual request
+ suite.mediaModule.MediaCreatePOSTHandler(ctx)
+
+ // check what's in storage *after* the request
+ storageKeysAfterRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // check response
+ suite.EqualValues(http.StatusAccepted, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ fmt.Println(string(b))
+
+ attachmentReply := &model.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(), model.MediaMeta{
+ Original: model.MediaDimensions{
+ Width: 1920,
+ Height: 1080,
+ Size: "1920x1080",
+ Aspect: 1.7777778,
+ },
+ Small: model.MediaDimensions{
+ Width: 256,
+ Height: 144,
+ Size: "256x144",
+ Aspect: 1.7777778,
+ },
+ Focus: model.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/api/client/status/status.go b/internal/api/client/status/status.go
new file mode 100644
index 000000000..ba9295623
--- /dev/null
+++ b/internal/api/client/status/status.go
@@ -0,0 +1,118 @@
+/*
+ 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 (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // IDKey is for status UUIDs
+ IDKey = "id"
+ // BasePath is the base path for serving the status API
+ BasePath = "/api/v1/statuses"
+ // BasePathWithID is just the base path with the ID key in it.
+ // Use this anywhere you need to know the ID of the status being queried.
+ BasePathWithID = BasePath + "/:" + IDKey
+
+ // ContextPath is used for fetching context of posts
+ ContextPath = BasePathWithID + "/context"
+
+ // FavouritedPath is for seeing who's faved a given status
+ FavouritedPath = BasePathWithID + "/favourited_by"
+ // FavouritePath is for posting a fave on a status
+ FavouritePath = BasePathWithID + "/favourite"
+ // UnfavouritePath is for removing a fave from a status
+ UnfavouritePath = BasePathWithID + "/unfavourite"
+
+ // RebloggedPath is for seeing who's boosted a given status
+ RebloggedPath = BasePathWithID + "/reblogged_by"
+ // ReblogPath is for boosting/reblogging a given status
+ ReblogPath = BasePathWithID + "/reblog"
+ // UnreblogPath is for undoing a boost/reblog of a given status
+ UnreblogPath = BasePathWithID + "/unreblog"
+
+ // BookmarkPath is for creating a bookmark on a given status
+ BookmarkPath = BasePathWithID + "/bookmark"
+ // UnbookmarkPath is for removing a bookmark from a given status
+ UnbookmarkPath = BasePathWithID + "/unbookmark"
+
+ // MutePath is for muting a given status so that notifications will no longer be received about it.
+ MutePath = BasePathWithID + "/mute"
+ // UnmutePath is for undoing an existing mute
+ UnmutePath = BasePathWithID + "/unmute"
+
+ // PinPath is for pinning a status to an account profile so that it's the first thing people see
+ PinPath = BasePathWithID + "/pin"
+ // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
+ UnpinPath = BasePathWithID + "/unpin"
+)
+
+// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
+ r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
+
+ r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
+ r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
+
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+// muxHandler is a little workaround to overcome the limitations of Gin
+func (m *Module) muxHandler(c *gin.Context) {
+ m.log.Debug("entering mux handler")
+ ru := c.Request.RequestURI
+
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, ContextPath) {
+ // TODO
+ } else if strings.HasPrefix(ru, FavouritedPath) {
+ m.StatusFavedByGETHandler(c)
+ } else {
+ m.StatusGETHandler(c)
+ }
+ }
+}
diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go
new file mode 100644
index 000000000..0f77820a1
--- /dev/null
+++ b/internal/api/client/status/status_test.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type StatusStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ federator federation.Federator
+ processor message.Processor
+ storage storage.Storage
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.Module
+}
diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go
new file mode 100644
index 000000000..02080b042
--- /dev/null
+++ b/internal/api/client/status/statuscreate.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 status
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// StatusCreatePOSTHandler deals with the creation of new statuses
+func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(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 := &model.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
+ }
+
+ mastoStatus, err := m.processor.StatusCreate(authed, form)
+ if err != nil {
+ l.Debugf("error processing status create: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
+
+func validateCreateStatus(form *model.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
+}
diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go
new file mode 100644
index 000000000..fb9b48f8a
--- /dev/null
+++ b/internal/api/client/status/statuscreate_test.go
@@ -0,0 +1,297 @@
+/*
+ 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_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusCreateTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusCreateTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusCreateTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// Post a new status with some custom visibility settings
+func (suite *StatusCreateTestSuite) TestPostNewStatus() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a brand new status! #helloworld"},
+ "spoiler_text": {"hello hello"},
+ "sensitive": {"true"},
+ "visibility_advanced": {"mutuals_only"},
+ "likeable": {"false"},
+ "replyable": {"false"},
+ "federated": {"false"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK from our call to the function
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.VisibilityPrivate, statusReply.Visibility)
+ assert.Len(suite.T(), statusReply.Tags, 1)
+ assert.Equal(suite.T(), model.Tag{
+ Name: "helloworld",
+ URL: "http://localhost:8080/tags/helloworld",
+ }, statusReply.Tags[0])
+
+ gtsTag := &gtsmodel.Tag{}
+ err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
+
+ assert.Len(suite.T(), statusReply.Emojis, 1)
+ mastoEmoji := statusReply.Emojis[0]
+ gtsEmoji := testrig.NewTestEmojis()["rainbow"]
+
+ assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
+ assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
+ assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
+}
+
+// Try to reply to a status that doesn't exist
+func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a reply to a status that doesn't exist"},
+ "spoiler_text": {"don't open cuz it won't work"},
+ "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
+}
+
+// Post a reply to the status of a local user that allows replies.
+func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
+ "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.VisibilityPublic, statusReply.Visibility)
+ assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
+ assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
+ assert.Len(suite.T(), statusReply.Mentions, 1)
+}
+
+// Take a media file which is currently not associated with a status, and attach it to a new status.
+func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here's an image attachment"},
+ "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ statusReply := &model.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(), model.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 := &gtsmodel.MediaAttachment{}
+ err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // convert it to a masto attachment
+ gtsAttachmentAsMasto, err := suite.tc.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/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go
new file mode 100644
index 000000000..e55416522
--- /dev/null
+++ b/internal/api/client/status/statusdelete.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusDELETEHandler verifies and handles deletion of a status
+func (m *Module) StatusDELETEHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusDELETEHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(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
+ }
+
+ mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status delete: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go
new file mode 100644
index 000000000..888589a8a
--- /dev/null
+++ b/internal/api/client/status/statusfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavePOSTHandler handles fave requests against a given status ID
+func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusFavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(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
+ }
+
+ mastoStatus, err := m.processor.StatusFave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status fave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go
new file mode 100644
index 000000000..2f779baed
--- /dev/null
+++ b/internal/api/client/status/statusfave_test.go
@@ -0,0 +1,158 @@
+/*
+ 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_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// fave a status
+func (suite *StatusFaveTestSuite) TestPostFave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.VisibilityPublic, statusReply.Visibility)
+ assert.True(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+}
+
+// try to fave a status that's not faveable
+func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.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":"bad request"}`, string(b))
+}
+
+func TestStatusFaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFaveTestSuite))
+}
diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go
new file mode 100644
index 000000000..799acb7d2
--- /dev/null
+++ b/internal/api/client/status/statusfavedby.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
+func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoAccounts)
+}
diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go
new file mode 100644
index 000000000..7b72df7bc
--- /dev/null
+++ b/internal/api/client/status/statusfavedby_test.go
@@ -0,0 +1,114 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFavedByTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFavedByTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFavedByTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFavedByTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavedByGETHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ accts := []model.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/api/client/status/statusget.go b/internal/api/client/status/statusget.go
new file mode 100644
index 000000000..c6239cb36
--- /dev/null
+++ b/internal/api/client/status/statusget.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusGETHandler is for handling requests to just get one status based on its ID
+func (m *Module) StatusGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusGet(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status get: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go
new file mode 100644
index 000000000..b31acebca
--- /dev/null
+++ b/internal/api/client/status/statusget_test.go
@@ -0,0 +1,117 @@
+/*
+ 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_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusGetTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusGetTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusGetTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusGetTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// 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 := &mastotypes.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(), mastotypes.VisibilityPrivate, statusReply.Visibility)
+ // assert.Len(suite.T(), statusReply.Tags, 1)
+ // assert.Equal(suite.T(), mastotypes.Tag{
+ // Name: "helloworld",
+ // URL: "http://localhost:8080/tags/helloworld",
+ // }, statusReply.Tags[0])
+
+ // gtsTag := &gtsmodel.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/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go
new file mode 100644
index 000000000..94fd662de
--- /dev/null
+++ b/internal/api/client/status/statusunfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
+func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusUnfavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(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
+ }
+
+ mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status unfave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go
new file mode 100644
index 000000000..44b1dd3a6
--- /dev/null
+++ b/internal/api/client/status/statusunfave_test.go
@@ -0,0 +1,170 @@
+/*
+ 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_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnfaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusUnfaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusUnfaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusUnfaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// unfave a status
+func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's already faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+// try to unfave a status that's already not faved
+func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's not faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.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))
+}