85
internal/api/client/account/account.go
Normal file
85
internal/api/client/account/account.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
40
internal/api/client/account/account_test.go
Normal file
40
internal/api/client/account/account_test.go
Normal file
@ -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
|
||||
}
|
113
internal/api/client/account/accountcreate.go
Normal file
113
internal/api/client/account/accountcreate.go
Normal file
@ -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
|
||||
}
|
388
internal/api/client/account/accountcreate_test.go
Normal file
388
internal/api/client/account/accountcreate_test.go
Normal file
@ -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 := >smodel.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 := >smodel.User{}
|
||||
// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
|
||||
// assert.Nil(suite.T(), err)
|
||||
// assert.NotNil(suite.T(), usr)
|
||||
|
||||
// // 2. user should have account id set to account we got above
|
||||
// assert.Equal(suite.T(), acct.ID, usr.AccountID)
|
||||
|
||||
// // 3. id should be set and parseable as a uuid
|
||||
// assert.NotNil(suite.T(), usr.ID)
|
||||
// _, err = uuid.Parse(usr.ID)
|
||||
// assert.Nil(suite.T(), err)
|
||||
|
||||
// // 4. locale should be equal to what we requested
|
||||
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
|
||||
|
||||
// // 5. created by application id should be equal to the app id
|
||||
// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
|
||||
|
||||
// // 6. password should be matcheable to what we set above
|
||||
// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
|
||||
// assert.Nil(suite.T(), err)
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
|
||||
// // only registered applications can create accounts, and we don't provide one here.
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have forbidden from our call to the function because we didn't auth
|
||||
// suite.EqualValues(http.StatusForbidden, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// // set a weak password
|
||||
// ctx.Request.Form.Set("password", "weak")
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// // set an invalid locale
|
||||
// ctx.Request.Form.Set("locale", "neverneverland")
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // close registrations
|
||||
// suite.config.AccountsConfig.OpenRegistration = false
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // remove reason
|
||||
// ctx.Request.Form.Set("reason", "")
|
||||
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // remove reason
|
||||
// ctx.Request.Form.Set("reason", "just cuz")
|
||||
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
|
||||
// }
|
||||
|
||||
// /*
|
||||
// TESTING: AccountUpdateCredentialsPATCHHandler
|
||||
// */
|
||||
|
||||
// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
|
||||
|
||||
// // put test local account in db
|
||||
// err := suite.db.Put(suite.testAccountLocal)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// // attach avatar to request
|
||||
// aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
|
||||
// assert.NoError(suite.T(), err)
|
||||
// body := &bytes.Buffer{}
|
||||
// writer := multipart.NewWriter(body)
|
||||
|
||||
// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// _, err = io.Copy(part, aviFile)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// err = aviFile.Close()
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// err = writer.Close()
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
|
||||
// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have OK because our request was valid
|
||||
// suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// // TODO: implement proper checks here
|
||||
// //
|
||||
// // b, err := ioutil.ReadAll(result.Body)
|
||||
// // assert.NoError(suite.T(), err)
|
||||
// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
// }
|
||||
|
||||
// func TestAccountCreateTestSuite(t *testing.T) {
|
||||
// suite.Run(t, new(AccountCreateTestSuite))
|
||||
// }
|
52
internal/api/client/account/accountget.go
Normal file
52
internal/api/client/account/accountget.go
Normal file
@ -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)
|
||||
}
|
71
internal/api/client/account/accountupdate.go
Normal file
71
internal/api/client/account/accountupdate.go
Normal file
@ -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)
|
||||
}
|
106
internal/api/client/account/accountupdate_test.go
Normal file
106
internal/api/client/account/accountupdate_test.go
Normal file
@ -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))
|
||||
}
|
48
internal/api/client/account/accountverify.go
Normal file
48
internal/api/client/account/accountverify.go
Normal file
@ -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)
|
||||
}
|
19
internal/api/client/account/accountverify_test.go
Normal file
19
internal/api/client/account/accountverify_test.go
Normal file
@ -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
|
58
internal/api/client/admin/admin.go
Normal file
58
internal/api/client/admin/admin.go
Normal file
@ -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
|
||||
}
|
94
internal/api/client/admin/emojicreate.go
Normal file
94
internal/api/client/admin/emojicreate.go
Normal file
@ -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)
|
||||
}
|
54
internal/api/client/app/app.go
Normal file
54
internal/api/client/app/app.go
Normal file
@ -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
|
||||
}
|
21
internal/api/client/app/app_test.go
Normal file
21
internal/api/client/app/app_test.go
Normal file
@ -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
|
79
internal/api/client/app/appcreate.go
Normal file
79
internal/api/client/app/appcreate.go
Normal file
@ -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)
|
||||
}
|
71
internal/api/client/auth/auth.go
Normal file
71
internal/api/client/auth/auth.go
Normal file
@ -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
|
||||
}
|
166
internal/api/client/auth/auth_test.go
Normal file
166
internal/api/client/auth/auth_test.go
Normal file
@ -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 = >smodel.Account{
|
||||
ID: acctID,
|
||||
Username: "test_user",
|
||||
}
|
||||
suite.testUser = >smodel.User{
|
||||
EncryptedPassword: string(encryptedPassword),
|
||||
Email: "user@example.org",
|
||||
AccountID: acctID,
|
||||
}
|
||||
suite.testClient = &oauth.Client{
|
||||
ID: "a-known-client-id",
|
||||
Secret: "some-secret",
|
||||
Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
|
||||
}
|
||||
suite.testApplication = >smodel.Application{
|
||||
Name: "a test application",
|
||||
Website: "https://some-application-website.com",
|
||||
RedirectURI: "http://localhost:8080",
|
||||
ClientID: "a-known-client-id",
|
||||
ClientSecret: "some-secret",
|
||||
Scopes: "read",
|
||||
VapidKey: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTest creates a postgres connection and creates the oauth_clients table before each test
|
||||
func (suite *AuthTestSuite) SetupTest() {
|
||||
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
db, err := db.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{},
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Application{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := suite.db.CreateTable(m); err != nil {
|
||||
logrus.Panicf("db connection error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
suite.oauthServer = oauth.New(suite.db, log)
|
||||
|
||||
if err := suite.db.Put(suite.testAccount); err != nil {
|
||||
logrus.Panicf("could not insert test account into db: %s", err)
|
||||
}
|
||||
if err := suite.db.Put(suite.testUser); err != nil {
|
||||
logrus.Panicf("could not insert test user into db: %s", err)
|
||||
}
|
||||
if err := suite.db.Put(suite.testClient); err != nil {
|
||||
logrus.Panicf("could not insert test client into db: %s", err)
|
||||
}
|
||||
if err := suite.db.Put(suite.testApplication); err != nil {
|
||||
logrus.Panicf("could not insert test application into db: %s", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TearDownTest drops the oauth_clients table and closes the pg connection after each test
|
||||
func (suite *AuthTestSuite) TearDownTest() {
|
||||
models := []interface{}{
|
||||
&oauth.Client{},
|
||||
&oauth.Token{},
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Application{},
|
||||
}
|
||||
for _, m := range models {
|
||||
if err := suite.db.DropTable(m); err != nil {
|
||||
logrus.Panicf("error dropping table: %s", err)
|
||||
}
|
||||
}
|
||||
if err := suite.db.Stop(context.Background()); err != nil {
|
||||
logrus.Panicf("error closing db connection: %s", err)
|
||||
}
|
||||
suite.db = nil
|
||||
}
|
||||
|
||||
func TestAuthTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AuthTestSuite))
|
||||
}
|
204
internal/api/client/auth/authorize.go
Normal file
204
internal/api/client/auth/authorize.go
Normal file
@ -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 := >smodel.Application{
|
||||
ClientID: clientID,
|
||||
}
|
||||
if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
|
||||
return
|
||||
}
|
||||
|
||||
// we can also use the userid of the user to fetch their username from the db to greet them nicely <3
|
||||
user := >smodel.User{
|
||||
ID: userID,
|
||||
}
|
||||
if err := m.db.GetByID(user.ID, user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
acct := >smodel.Account{
|
||||
ID: user.AccountID,
|
||||
}
|
||||
|
||||
if err := m.db.GetByID(acct.ID, acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Finally we should also get the redirect and scope of this particular request, as stored in the session.
|
||||
redirect, ok := s.Get("redirect_uri").(string)
|
||||
if !ok || redirect == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"})
|
||||
return
|
||||
}
|
||||
scope, ok := s.Get("scope").(string)
|
||||
if !ok || scope == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"})
|
||||
return
|
||||
}
|
||||
|
||||
// the authorize template will display a form to the user where they can get some information
|
||||
// about the app that's trying to authorize, and the scope of the request.
|
||||
// They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler
|
||||
l.Trace("serving authorize html")
|
||||
c.HTML(http.StatusOK, "authorize.tmpl", gin.H{
|
||||
"appname": app.Name,
|
||||
"appwebsite": app.Website,
|
||||
"redirect": redirect,
|
||||
"scope": scope,
|
||||
"user": acct.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
|
||||
// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
|
||||
// so we should proceed with the authentication flow and generate an oauth token for them if we can.
|
||||
// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
|
||||
func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "AuthorizePOSTHandler")
|
||||
s := sessions.Default(c)
|
||||
|
||||
// At this point we know the user has said 'yes' to allowing the application and oauth client
|
||||
// work for them, so we can set the
|
||||
|
||||
// We need to retrieve the original form submitted to the authorizeGEThandler, and
|
||||
// recreate it on the request so that it can be used further by the oauth2 library.
|
||||
// So first fetch all the values from the session.
|
||||
forceLogin, ok := s.Get("force_login").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"})
|
||||
return
|
||||
}
|
||||
responseType, ok := s.Get("response_type").(string)
|
||||
if !ok || responseType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"})
|
||||
return
|
||||
}
|
||||
clientID, ok := s.Get("client_id").(string)
|
||||
if !ok || clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"})
|
||||
return
|
||||
}
|
||||
redirectURI, ok := s.Get("redirect_uri").(string)
|
||||
if !ok || redirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"})
|
||||
return
|
||||
}
|
||||
scope, ok := s.Get("scope").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"})
|
||||
return
|
||||
}
|
||||
userID, ok := s.Get("userid").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"})
|
||||
return
|
||||
}
|
||||
// we're done with the session so we can clear it now
|
||||
s.Clear()
|
||||
|
||||
// now set the values on the request
|
||||
values := url.Values{}
|
||||
values.Set("force_login", forceLogin)
|
||||
values.Set("response_type", responseType)
|
||||
values.Set("client_id", clientID)
|
||||
values.Set("redirect_uri", redirectURI)
|
||||
values.Set("scope", scope)
|
||||
values.Set("userid", userID)
|
||||
c.Request.Form = values
|
||||
l.Tracef("values on request set to %+v", c.Request.Form)
|
||||
|
||||
// and proceed with authorization using the oauth2 library
|
||||
if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores
|
||||
// the values in the form into the session.
|
||||
func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
|
||||
s := sessions.Default(c)
|
||||
|
||||
// first make sure they've filled out the authorize form with the required values
|
||||
form := &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()
|
||||
}
|
76
internal/api/client/auth/middleware.go
Normal file
76
internal/api/client/auth/middleware.go
Normal file
@ -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 := >smodel.User{}
|
||||
if err := m.db.GetByID(uid, user); err != nil || user == nil {
|
||||
l.Warnf("no user found for validated uid %s", uid)
|
||||
return
|
||||
}
|
||||
c.Set(oauth.SessionAuthorizedUser, user)
|
||||
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
|
||||
|
||||
acct := >smodel.Account{}
|
||||
if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
|
||||
l.Warnf("no account found for validated user %s", uid)
|
||||
return
|
||||
}
|
||||
c.Set(oauth.SessionAuthorizedAccount, acct)
|
||||
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct)
|
||||
}
|
||||
|
||||
// check for application token
|
||||
if cid := ti.GetClientID(); cid != "" {
|
||||
l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
|
||||
app := >smodel.Application{}
|
||||
if err := m.db.GetWhere("client_id", cid, app); err != nil {
|
||||
l.Tracef("no app found for client %s", cid)
|
||||
}
|
||||
c.Set(oauth.SessionAuthorizedApplication, app)
|
||||
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
|
||||
}
|
||||
}
|
116
internal/api/client/auth/signin.go
Normal file
116
internal/api/client/auth/signin.go
Normal file
@ -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 := >smodel.User{}
|
||||
|
||||
if err := m.db.GetWhere("email", email, gtsUser); err != nil {
|
||||
l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
|
||||
return incorrectPassword()
|
||||
}
|
||||
|
||||
// make sure a password is actually set and bail if not
|
||||
if gtsUser.EncryptedPassword == "" {
|
||||
l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email)
|
||||
return incorrectPassword()
|
||||
}
|
||||
|
||||
// compare the provided password with the encrypted one from the db, bail if they don't match
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil {
|
||||
l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err)
|
||||
return incorrectPassword()
|
||||
}
|
||||
|
||||
// If we've made it this far the email/password is correct, so we can just return the id of the user.
|
||||
userid = gtsUser.ID
|
||||
l.Tracef("returning (%s, %s)", userid, err)
|
||||
return
|
||||
}
|
||||
|
||||
// incorrectPassword is just a little helper function to use in the ValidatePassword function
|
||||
func incorrectPassword() (string, error) {
|
||||
return "", errors.New("password/email combination was incorrect")
|
||||
}
|
36
internal/api/client/auth/token.go
Normal file
36
internal/api/client/auth/token.go
Normal file
@ -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()})
|
||||
}
|
||||
}
|
82
internal/api/client/fileserver/fileserver.go
Normal file
82
internal/api/client/fileserver/fileserver.go
Normal file
@ -0,0 +1,82 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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{}{
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
94
internal/api/client/fileserver/servefile.go
Normal file
94
internal/api/client/fileserver/servefile.go
Normal file
@ -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)
|
||||
}
|
163
internal/api/client/fileserver/servefile_test.go
Normal file
163
internal/api/client/fileserver/servefile_test.go
Normal file
@ -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))
|
||||
}
|
71
internal/api/client/media/media.go
Normal file
71
internal/api/client/media/media.go
Normal file
@ -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{}{
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
91
internal/api/client/media/mediacreate.go
Normal file
91
internal/api/client/media/mediacreate.go
Normal file
@ -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
|
||||
}
|
200
internal/api/client/media/mediacreate_test.go
Normal file
200
internal/api/client/media/mediacreate_test.go
Normal file
@ -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))
|
||||
}
|
118
internal/api/client/status/status.go
Normal file
118
internal/api/client/status/status.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
58
internal/api/client/status/status_test.go
Normal file
58
internal/api/client/status/status_test.go
Normal file
@ -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
|
||||
}
|
130
internal/api/client/status/statuscreate.go
Normal file
130
internal/api/client/status/statuscreate.go
Normal file
@ -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
|
||||
}
|
297
internal/api/client/status/statuscreate_test.go
Normal file
297
internal/api/client/status/statuscreate_test.go
Normal file
@ -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 := >smodel.Tag{}
|
||||
err = suite.db.GetWhere("name", "helloworld", gtsTag)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
|
||||
}
|
||||
|
||||
func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
|
||||
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.TokenToOauthToken(t)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{
|
||||
"status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
|
||||
}
|
||||
suite.statusModule.StatusCreatePOSTHandler(ctx)
|
||||
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &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 := >smodel.MediaAttachment{}
|
||||
err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// convert it to a masto attachment
|
||||
gtsAttachmentAsMasto, err := suite.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))
|
||||
}
|
60
internal/api/client/status/statusdelete.go
Normal file
60
internal/api/client/status/statusdelete.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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)
|
||||
}
|
60
internal/api/client/status/statusfave.go
Normal file
60
internal/api/client/status/statusfave.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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)
|
||||
}
|
158
internal/api/client/status/statusfave_test.go
Normal file
158
internal/api/client/status/statusfave_test.go
Normal file
@ -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))
|
||||
}
|
60
internal/api/client/status/statusfavedby.go
Normal file
60
internal/api/client/status/statusfavedby.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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)
|
||||
}
|
114
internal/api/client/status/statusfavedby_test.go
Normal file
114
internal/api/client/status/statusfavedby_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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))
|
||||
}
|
60
internal/api/client/status/statusget.go
Normal file
60
internal/api/client/status/statusget.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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)
|
||||
}
|
117
internal/api/client/status/statusget_test.go
Normal file
117
internal/api/client/status/statusget_test.go
Normal file
@ -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 := >smodel.Tag{}
|
||||
// err = suite.db.GetWhere("name", "helloworld", gtsTag)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
|
||||
}
|
||||
|
||||
func TestStatusGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusGetTestSuite))
|
||||
}
|
60
internal/api/client/status/statusunfave.go
Normal file
60
internal/api/client/status/statusunfave.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package 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)
|
||||
}
|
170
internal/api/client/status/statusunfave_test.go
Normal file
170
internal/api/client/status/statusunfave_test.go
Normal file
@ -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))
|
||||
}
|
Reference in New Issue
Block a user