Big restructuring and initial work on activitypub
This commit is contained in:
Tobi Smethurst
2021-05-08 14:25:55 +02:00
committed by GitHub
parent ac9adb172b
commit 6f5c045284
183 changed files with 7391 additions and 5414 deletions

View 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)
}
}
}

View 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
}

View 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
}

View 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 := &gtsmodel.Account{}
// err = suite.db.GetLocalAccountByUsername("test_user", acct)
// assert.NoError(suite.T(), err)
// assert.NotNil(suite.T(), acct)
// // 2. reason should be set
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
// // 3. display name should be equal to username by default
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
// // 4. domain should be nil because this is a local account
// assert.Nil(suite.T(), nil, acct.Domain)
// // 5. id should be set and parseable as a uuid
// assert.NotNil(suite.T(), acct.ID)
// _, err = uuid.Parse(acct.ID)
// assert.Nil(suite.T(), err)
// // 6. private and public key should be set
// assert.NotNil(suite.T(), acct.PrivateKey)
// assert.NotNil(suite.T(), acct.PublicKey)
// // check new user
// // 1. we should be able to get the new user from the db
// usr := &gtsmodel.User{}
// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
// assert.Nil(suite.T(), err)
// assert.NotNil(suite.T(), usr)
// // 2. user should have account id set to account we got above
// assert.Equal(suite.T(), acct.ID, usr.AccountID)
// // 3. id should be set and parseable as a uuid
// assert.NotNil(suite.T(), usr.ID)
// _, err = uuid.Parse(usr.ID)
// assert.Nil(suite.T(), err)
// // 4. locale should be equal to what we requested
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
// // 5. created by application id should be equal to the app id
// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
// // 6. password should be matcheable to what we set above
// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
// assert.Nil(suite.T(), err)
// }
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
// // only registered applications can create accounts, and we don't provide one here.
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// // 1. we should have forbidden from our call to the function because we didn't auth
// suite.EqualValues(http.StatusForbidden, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// // set a weak password
// ctx.Request.Form.Set("password", "weak")
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// // set an invalid locale
// ctx.Request.Form.Set("locale", "neverneverland")
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// // close registrations
// suite.config.AccountsConfig.OpenRegistration = false
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// // remove reason
// ctx.Request.Form.Set("reason", "")
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
// }
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
// ctx.Request.Form = suite.newUserFormHappyPath
// // remove reason
// ctx.Request.Form.Set("reason", "just cuz")
// suite.accountModule.AccountCreatePOSTHandler(ctx)
// // check response
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// b, err := ioutil.ReadAll(result.Body)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
// }
// /*
// TESTING: AccountUpdateCredentialsPATCHHandler
// */
// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
// // put test local account in db
// err := suite.db.Put(suite.testAccountLocal)
// assert.NoError(suite.T(), err)
// // attach avatar to request
// aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
// assert.NoError(suite.T(), err)
// body := &bytes.Buffer{}
// writer := multipart.NewWriter(body)
// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
// assert.NoError(suite.T(), err)
// _, err = io.Copy(part, aviFile)
// assert.NoError(suite.T(), err)
// err = aviFile.Close()
// assert.NoError(suite.T(), err)
// err = writer.Close()
// assert.NoError(suite.T(), err)
// // setup
// recorder := httptest.NewRecorder()
// ctx, _ := gin.CreateTestContext(recorder)
// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
// // check response
// // 1. we should have OK because our request was valid
// suite.EqualValues(http.StatusOK, recorder.Code)
// // 2. we should have an error message in the result body
// result := recorder.Result()
// defer result.Body.Close()
// // TODO: implement proper checks here
// //
// // b, err := ioutil.ReadAll(result.Body)
// // assert.NoError(suite.T(), err)
// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
// }
// func TestAccountCreateTestSuite(t *testing.T) {
// suite.Run(t, new(AccountCreateTestSuite))
// }

View 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)
}

View 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)
}

View 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))
}

View 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)
}

View 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

View 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
}

View 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)
}

View 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
}

View 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

View 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)
}

View 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
}

View 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 = &gtsmodel.Account{
ID: acctID,
Username: "test_user",
}
suite.testUser = &gtsmodel.User{
EncryptedPassword: string(encryptedPassword),
Email: "user@example.org",
AccountID: acctID,
}
suite.testClient = &oauth.Client{
ID: "a-known-client-id",
Secret: "some-secret",
Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
}
suite.testApplication = &gtsmodel.Application{
Name: "a test application",
Website: "https://some-application-website.com",
RedirectURI: "http://localhost:8080",
ClientID: "a-known-client-id",
ClientSecret: "some-secret",
Scopes: "read",
VapidKey: uuid.NewString(),
}
}
// SetupTest creates a postgres connection and creates the oauth_clients table before each test
func (suite *AuthTestSuite) SetupTest() {
log := logrus.New()
log.SetLevel(logrus.TraceLevel)
db, err := db.NewPostgresService(context.Background(), suite.config, log)
if err != nil {
logrus.Panicf("error creating database connection: %s", err)
}
suite.db = db
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
&gtsmodel.User{},
&gtsmodel.Account{},
&gtsmodel.Application{},
}
for _, m := range models {
if err := suite.db.CreateTable(m); err != nil {
logrus.Panicf("db connection error: %s", err)
}
}
suite.oauthServer = oauth.New(suite.db, log)
if err := suite.db.Put(suite.testAccount); err != nil {
logrus.Panicf("could not insert test account into db: %s", err)
}
if err := suite.db.Put(suite.testUser); err != nil {
logrus.Panicf("could not insert test user into db: %s", err)
}
if err := suite.db.Put(suite.testClient); err != nil {
logrus.Panicf("could not insert test client into db: %s", err)
}
if err := suite.db.Put(suite.testApplication); err != nil {
logrus.Panicf("could not insert test application into db: %s", err)
}
}
// TearDownTest drops the oauth_clients table and closes the pg connection after each test
func (suite *AuthTestSuite) TearDownTest() {
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
&gtsmodel.User{},
&gtsmodel.Account{},
&gtsmodel.Application{},
}
for _, m := range models {
if err := suite.db.DropTable(m); err != nil {
logrus.Panicf("error dropping table: %s", err)
}
}
if err := suite.db.Stop(context.Background()); err != nil {
logrus.Panicf("error closing db connection: %s", err)
}
suite.db = nil
}
func TestAuthTestSuite(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}

View 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 := &gtsmodel.Application{
ClientID: clientID,
}
if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
return
}
// we can also use the userid of the user to fetch their username from the db to greet them nicely <3
user := &gtsmodel.User{
ID: userID,
}
if err := m.db.GetByID(user.ID, user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
acct := &gtsmodel.Account{
ID: user.AccountID,
}
if err := m.db.GetByID(acct.ID, acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Finally we should also get the redirect and scope of this particular request, as stored in the session.
redirect, ok := s.Get("redirect_uri").(string)
if !ok || redirect == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"})
return
}
scope, ok := s.Get("scope").(string)
if !ok || scope == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"})
return
}
// the authorize template will display a form to the user where they can get some information
// about the app that's trying to authorize, and the scope of the request.
// They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler
l.Trace("serving authorize html")
c.HTML(http.StatusOK, "authorize.tmpl", gin.H{
"appname": app.Name,
"appwebsite": app.Website,
"redirect": redirect,
"scope": scope,
"user": acct.Username,
})
}
// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
// so we should proceed with the authentication flow and generate an oauth token for them if we can.
// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
l := m.log.WithField("func", "AuthorizePOSTHandler")
s := sessions.Default(c)
// At this point we know the user has said 'yes' to allowing the application and oauth client
// work for them, so we can set the
// We need to retrieve the original form submitted to the authorizeGEThandler, and
// recreate it on the request so that it can be used further by the oauth2 library.
// So first fetch all the values from the session.
forceLogin, ok := s.Get("force_login").(string)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"})
return
}
responseType, ok := s.Get("response_type").(string)
if !ok || responseType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"})
return
}
clientID, ok := s.Get("client_id").(string)
if !ok || clientID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"})
return
}
redirectURI, ok := s.Get("redirect_uri").(string)
if !ok || redirectURI == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"})
return
}
scope, ok := s.Get("scope").(string)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"})
return
}
userID, ok := s.Get("userid").(string)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"})
return
}
// we're done with the session so we can clear it now
s.Clear()
// now set the values on the request
values := url.Values{}
values.Set("force_login", forceLogin)
values.Set("response_type", responseType)
values.Set("client_id", clientID)
values.Set("redirect_uri", redirectURI)
values.Set("scope", scope)
values.Set("userid", userID)
c.Request.Form = values
l.Tracef("values on request set to %+v", c.Request.Form)
// and proceed with authorization using the oauth2 library
if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores
// the values in the form into the session.
func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
s := sessions.Default(c)
// first make sure they've filled out the authorize form with the required values
form := &model.OAuthAuthorize{}
if err := c.ShouldBind(form); err != nil {
return err
}
l.Tracef("parsed form: %+v", form)
// these fields are *required* so check 'em
if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
return errors.New("missing one of: response_type, client_id or redirect_uri")
}
// set default scope to read
if form.Scope == "" {
form.Scope = "read"
}
// save these values from the form so we can use them elsewhere in the session
s.Set("force_login", form.ForceLogin)
s.Set("response_type", form.ResponseType)
s.Set("client_id", form.ClientID)
s.Set("redirect_uri", form.RedirectURI)
s.Set("scope", form.Scope)
return s.Save()
}

View 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 := &gtsmodel.User{}
if err := m.db.GetByID(uid, user); err != nil || user == nil {
l.Warnf("no user found for validated uid %s", uid)
return
}
c.Set(oauth.SessionAuthorizedUser, user)
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
acct := &gtsmodel.Account{}
if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
l.Warnf("no account found for validated user %s", uid)
return
}
c.Set(oauth.SessionAuthorizedAccount, acct)
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct)
}
// check for application token
if cid := ti.GetClientID(); cid != "" {
l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
app := &gtsmodel.Application{}
if err := m.db.GetWhere("client_id", cid, app); err != nil {
l.Tracef("no app found for client %s", cid)
}
c.Set(oauth.SessionAuthorizedApplication, app)
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
}
}

View 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 := &gtsmodel.User{}
if err := m.db.GetWhere("email", email, gtsUser); err != nil {
l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
return incorrectPassword()
}
// make sure a password is actually set and bail if not
if gtsUser.EncryptedPassword == "" {
l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email)
return incorrectPassword()
}
// compare the provided password with the encrypted one from the db, bail if they don't match
if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil {
l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err)
return incorrectPassword()
}
// If we've made it this far the email/password is correct, so we can just return the id of the user.
userid = gtsUser.ID
l.Tracef("returning (%s, %s)", userid, err)
return
}
// incorrectPassword is just a little helper function to use in the ValidatePassword function
func incorrectPassword() (string, error) {
return "", errors.New("password/email combination was incorrect")
}

View 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()})
}
}

View 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{}{
&gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := db.CreateTable(m); err != nil {
return fmt.Errorf("error creating table: %s", err)
}
}
return nil
}

View 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)
}

View 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))
}

View 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{}{
&gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := db.CreateTable(m); err != nil {
return fmt.Errorf("error creating table: %s", err)
}
}
return nil
}

View 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
}

View 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))
}

View 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)
}
}
}

View 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
}

View 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
}

View 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 := &gtsmodel.Tag{}
err = suite.db.GetWhere("name", "helloworld", gtsTag)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
}
func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
ctx.Request.Form = url.Values{
"status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
}
suite.statusModule.StatusCreatePOSTHandler(ctx)
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "", statusReply.SpoilerText)
assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
assert.Len(suite.T(), statusReply.Emojis, 1)
mastoEmoji := statusReply.Emojis[0]
gtsEmoji := testrig.NewTestEmojis()["rainbow"]
assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
}
// Try to reply to a status that doesn't exist
func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
ctx.Request.Form = url.Values{
"status": {"this is a reply to a status that doesn't exist"},
"spoiler_text": {"don't open cuz it won't work"},
"in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
}
suite.statusModule.StatusCreatePOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusBadRequest, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
}
// Post a reply to the status of a local user that allows replies.
func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
ctx.Request.Form = url.Values{
"status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
"in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
}
suite.statusModule.StatusCreatePOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "", statusReply.SpoilerText)
assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
assert.False(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
assert.Len(suite.T(), statusReply.Mentions, 1)
}
// Take a media file which is currently not associated with a status, and attach it to a new status.
func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
ctx.Request.Form = url.Values{
"status": {"here's an image attachment"},
"media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
}
suite.statusModule.StatusCreatePOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
fmt.Println(string(b))
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "", statusReply.SpoilerText)
assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
assert.False(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
// there should be one media attachment
assert.Len(suite.T(), statusReply.MediaAttachments, 1)
// get the updated media attachment from the database
gtsAttachment := &gtsmodel.MediaAttachment{}
err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
assert.NoError(suite.T(), err)
// convert it to a masto attachment
gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)
assert.NoError(suite.T(), err)
// compare it with what we have now
assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
// the status id of the attachment should now be set to the id of the status we just created
assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
}
func TestStatusCreateTestSuite(t *testing.T) {
suite.Run(t, new(StatusCreateTestSuite))
}

View 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)
}

View 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)
}

View 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))
}

View 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)
}

View 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))
}

View 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)
}

View 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 := &gtsmodel.Tag{}
// err = suite.db.GetWhere("name", "helloworld", gtsTag)
// assert.NoError(suite.T(), err)
// assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
}
func TestStatusGetTestSuite(t *testing.T) {
suite.Run(t, new(StatusGetTestSuite))
}

View 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)
}

View 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))
}