parent
ac9adb172b
commit
6f5c045284
1
go.mod
1
go.mod
@ -8,6 +8,7 @@ require (
|
||||
github.com/gin-contrib/sessions v0.0.3
|
||||
github.com/gin-gonic/gin v1.6.3
|
||||
github.com/go-fed/activity v1.0.0
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5
|
||||
github.com/go-pg/pg/extra/pgdebug v0.2.0
|
||||
github.com/go-pg/pg/v10 v10.8.0
|
||||
github.com/golang/mock v1.4.4 // indirect
|
||||
|
@ -16,18 +16,22 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface.
|
||||
package apimodule
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set
|
||||
// ClientModule represents a chunk of code (usually contained in a single package) that adds a set
|
||||
// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
|
||||
// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
|
||||
type ClientAPIModule interface {
|
||||
type ClientModule interface {
|
||||
Route(s router.Router) error
|
||||
}
|
||||
|
||||
// FederationModule represents a chunk of code (usually contained in a single package) that adds a set
|
||||
// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
|
||||
// Unlike ClientAPIModule, federation API module is not intended to be interacted with by clients directly -- it is primarily a server-to-server interface.
|
||||
type FederationModule interface {
|
||||
Route(s router.Router) error
|
||||
CreateTables(db db.DB) error
|
||||
}
|
@ -19,20 +19,15 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -52,21 +47,15 @@ const (
|
||||
// Module implements the ClientAPIModule interface for account-related actions
|
||||
type Module struct {
|
||||
config *config.Config
|
||||
db db.DB
|
||||
oauthServer oauth.Server
|
||||
mediaHandler media.Handler
|
||||
mastoConverter mastotypes.Converter
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new account module
|
||||
func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
config: config,
|
||||
db: db,
|
||||
oauthServer: oauthServer,
|
||||
mediaHandler: mediaHandler,
|
||||
mastoConverter: mastoConverter,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@ -79,27 +68,6 @@ func (m *Module) Route(r router.Router) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables creates the required tables for this module in the given database
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) muxHandler(c *gin.Context) {
|
||||
ru := c.Request.RequestURI
|
||||
switch c.Request.Method {
|
40
internal/api/client/account/account_test.go
Normal file
40
internal/api/client/account/account_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package account_test
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
)
|
||||
|
||||
// nolint
|
||||
type AccountStandardTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
tc typeutils.TypeConverter
|
||||
storage storage.Storage
|
||||
federator federation.Federator
|
||||
processor message.Processor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
accountModule *account.Module
|
||||
}
|
@ -20,18 +20,14 @@ package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/oauth2/v4"
|
||||
)
|
||||
|
||||
// AccountCreatePOSTHandler handles create account requests, validates them,
|
||||
@ -39,7 +35,7 @@ import (
|
||||
// It should be served as a POST at /api/v1/accounts
|
||||
func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "accountCreatePOSTHandler")
|
||||
authed, err := oauth.MustAuth(c, true, true, false, false)
|
||||
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()})
|
||||
@ -47,7 +43,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
l.Trace("parsing request form")
|
||||
form := &mastotypes.AccountCreateRequest{}
|
||||
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"})
|
||||
@ -55,7 +51,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
l.Tracef("validating form %+v", form)
|
||||
if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil {
|
||||
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
|
||||
@ -70,7 +66,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application)
|
||||
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()})
|
||||
@ -80,41 +78,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, ti)
|
||||
}
|
||||
|
||||
// accountCreate does the dirty work of making an account and user in the database.
|
||||
// It then returns a token to the caller, for use with the new account, as per the
|
||||
// spec here: https://docs.joinmastodon.org/methods/accounts/
|
||||
func (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) {
|
||||
l := m.log.WithField("func", "accountCreate")
|
||||
|
||||
// don't store a reason if we don't require one
|
||||
reason := form.Reason
|
||||
if !m.config.AccountsConfig.ReasonRequired {
|
||||
reason = ""
|
||||
}
|
||||
|
||||
l.Trace("creating new username and account")
|
||||
user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new signup in the database: %s", err)
|
||||
}
|
||||
|
||||
l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID)
|
||||
accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)
|
||||
}
|
||||
|
||||
return &mastotypes.Token{
|
||||
AccessToken: accessToken.GetAccess(),
|
||||
TokenType: "Bearer",
|
||||
Scope: accessToken.GetScope(),
|
||||
CreatedAt: accessToken.GetAccessCreateAt().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateCreateAccount checks through all the necessary prerequisites for creating a new account,
|
||||
// according to the provided account create request. If the account isn't eligible, an error will be returned.
|
||||
func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error {
|
||||
func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error {
|
||||
if !c.OpenRegistration {
|
||||
return errors.New("registration is not open for this server")
|
||||
}
|
||||
@ -143,13 +109,5 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco
|
||||
return err
|
||||
}
|
||||
|
||||
if err := database.IsEmailAvailable(form.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := database.IsUsernameAvailable(form.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
388
internal/api/client/account/accountcreate_test.go
Normal file
388
internal/api/client/account/accountcreate_test.go
Normal file
@ -0,0 +1,388 @@
|
||||
// /*
|
||||
// GoToSocial
|
||||
// Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// */
|
||||
|
||||
package account_test
|
||||
|
||||
// import (
|
||||
// "bytes"
|
||||
// "encoding/json"
|
||||
// "fmt"
|
||||
// "io"
|
||||
// "io/ioutil"
|
||||
// "mime/multipart"
|
||||
// "net/http"
|
||||
// "net/http/httptest"
|
||||
// "os"
|
||||
// "testing"
|
||||
|
||||
// "github.com/gin-gonic/gin"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/stretchr/testify/assert"
|
||||
// "github.com/stretchr/testify/suite"
|
||||
// "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
|
||||
// "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
// "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
// "github.com/superseriousbusiness/gotosocial/testrig"
|
||||
|
||||
// "github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
// "golang.org/x/crypto/bcrypt"
|
||||
// )
|
||||
|
||||
// type AccountCreateTestSuite struct {
|
||||
// AccountStandardTestSuite
|
||||
// }
|
||||
|
||||
// func (suite *AccountCreateTestSuite) SetupSuite() {
|
||||
// suite.testTokens = testrig.NewTestTokens()
|
||||
// suite.testClients = testrig.NewTestClients()
|
||||
// suite.testApplications = testrig.NewTestApplications()
|
||||
// suite.testUsers = testrig.NewTestUsers()
|
||||
// suite.testAccounts = testrig.NewTestAccounts()
|
||||
// suite.testAttachments = testrig.NewTestAttachments()
|
||||
// suite.testStatuses = testrig.NewTestStatuses()
|
||||
// }
|
||||
|
||||
// func (suite *AccountCreateTestSuite) SetupTest() {
|
||||
// suite.config = testrig.NewTestConfig()
|
||||
// suite.db = testrig.NewTestDB()
|
||||
// suite.storage = testrig.NewTestStorage()
|
||||
// suite.log = testrig.NewTestLog()
|
||||
// suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
|
||||
// suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
|
||||
// suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
|
||||
// testrig.StandardDBSetup(suite.db)
|
||||
// testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
// }
|
||||
|
||||
// func (suite *AccountCreateTestSuite) TearDownTest() {
|
||||
// testrig.StandardDBTeardown(suite.db)
|
||||
// testrig.StandardStorageTeardown(suite.storage)
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
|
||||
// // and at the end of it a new user and account should be added into the database.
|
||||
// //
|
||||
// // This is the handler served at /api/v1/accounts as POST
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
|
||||
|
||||
// t := suite.testTokens["local_account_1"]
|
||||
// oauthToken := oauth.TokenToOauthToken(t)
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have OK from our call to the function
|
||||
// suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// // 2. we should have a token in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// t := &model.Token{}
|
||||
// err = json.Unmarshal(b, t)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
|
||||
|
||||
// // check new account
|
||||
|
||||
// // 1. we should be able to get the new account from the db
|
||||
// acct := >smodel.Account{}
|
||||
// err = suite.db.GetLocalAccountByUsername("test_user", acct)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.NotNil(suite.T(), acct)
|
||||
// // 2. reason should be set
|
||||
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
|
||||
// // 3. display name should be equal to username by default
|
||||
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
|
||||
// // 4. domain should be nil because this is a local account
|
||||
// assert.Nil(suite.T(), nil, acct.Domain)
|
||||
// // 5. id should be set and parseable as a uuid
|
||||
// assert.NotNil(suite.T(), acct.ID)
|
||||
// _, err = uuid.Parse(acct.ID)
|
||||
// assert.Nil(suite.T(), err)
|
||||
// // 6. private and public key should be set
|
||||
// assert.NotNil(suite.T(), acct.PrivateKey)
|
||||
// assert.NotNil(suite.T(), acct.PublicKey)
|
||||
|
||||
// // check new user
|
||||
|
||||
// // 1. we should be able to get the new user from the db
|
||||
// usr := >smodel.User{}
|
||||
// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
|
||||
// assert.Nil(suite.T(), err)
|
||||
// assert.NotNil(suite.T(), usr)
|
||||
|
||||
// // 2. user should have account id set to account we got above
|
||||
// assert.Equal(suite.T(), acct.ID, usr.AccountID)
|
||||
|
||||
// // 3. id should be set and parseable as a uuid
|
||||
// assert.NotNil(suite.T(), usr.ID)
|
||||
// _, err = uuid.Parse(usr.ID)
|
||||
// assert.Nil(suite.T(), err)
|
||||
|
||||
// // 4. locale should be equal to what we requested
|
||||
// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
|
||||
|
||||
// // 5. created by application id should be equal to the app id
|
||||
// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
|
||||
|
||||
// // 6. password should be matcheable to what we set above
|
||||
// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
|
||||
// assert.Nil(suite.T(), err)
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
|
||||
// // only registered applications can create accounts, and we don't provide one here.
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have forbidden from our call to the function because we didn't auth
|
||||
// suite.EqualValues(http.StatusForbidden, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// // set a weak password
|
||||
// ctx.Request.Form.Set("password", "weak")
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// // set an invalid locale
|
||||
// ctx.Request.Form.Set("locale", "neverneverland")
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // close registrations
|
||||
// suite.config.AccountsConfig.OpenRegistration = false
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // remove reason
|
||||
// ctx.Request.Form.Set("reason", "")
|
||||
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
|
||||
// }
|
||||
|
||||
// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
|
||||
// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// // remove reason
|
||||
// ctx.Request.Form.Set("reason", "just cuz")
|
||||
|
||||
// suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
|
||||
// }
|
||||
|
||||
// /*
|
||||
// TESTING: AccountUpdateCredentialsPATCHHandler
|
||||
// */
|
||||
|
||||
// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
|
||||
|
||||
// // put test local account in db
|
||||
// err := suite.db.Put(suite.testAccountLocal)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// // attach avatar to request
|
||||
// aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
|
||||
// assert.NoError(suite.T(), err)
|
||||
// body := &bytes.Buffer{}
|
||||
// writer := multipart.NewWriter(body)
|
||||
|
||||
// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// _, err = io.Copy(part, aviFile)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// err = aviFile.Close()
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// err = writer.Close()
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// // setup
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
|
||||
// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
|
||||
// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have OK because our request was valid
|
||||
// suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// // 2. we should have an error message in the result body
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// // TODO: implement proper checks here
|
||||
// //
|
||||
// // b, err := ioutil.ReadAll(result.Body)
|
||||
// // assert.NoError(suite.T(), err)
|
||||
// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
// }
|
||||
|
||||
// func TestAccountCreateTestSuite(t *testing.T) {
|
||||
// suite.Run(t, new(AccountCreateTestSuite))
|
||||
// }
|
@ -22,8 +22,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountGETHandler serves the account information held by the server in response to a GET
|
||||
@ -31,25 +30,21 @@ import (
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
|
||||
if _, ok := err.(db.ErrNoEntries); ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount)
|
||||
acctInfo, err := m.processor.AccountGet(authed, targetAcctID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
71
internal/api/client/account/accountupdate.go
Normal file
71
internal/api/client/account/accountupdate.go
Normal file
@ -0,0 +1,71 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
|
||||
// It should be served as a PATCH at /api/v1/accounts/update_credentials
|
||||
//
|
||||
// TODO: this can be optimized massively by building up a picture of what we want the new account
|
||||
// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
|
||||
// which is not gonna make the database very happy when lots of requests are going through.
|
||||
// This way it would also be safer because the update won't happen until *all* the fields are validated.
|
||||
// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
|
||||
func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
|
||||
authed, err := oauth.Authed(c, true, false, false, true)
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.Tracef("retrieved account %+v", authed.Account.ID)
|
||||
|
||||
l.Trace("parsing request form")
|
||||
form := &model.UpdateCredentialsRequest{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// if everything on the form is nil, then nothing has been set and we shouldn't continue
|
||||
if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
|
||||
l.Debugf("could not parse form from request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
|
||||
return
|
||||
}
|
||||
|
||||
acctSensitive, err := m.processor.AccountUpdate(authed, form)
|
||||
if err != nil {
|
||||
l.Debugf("could not update account: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
|
||||
c.JSON(http.StatusOK, acctSensitive)
|
||||
}
|
106
internal/api/client/account/accountupdate_test.go
Normal file
106
internal/api/client/account/accountupdate_test.go
Normal file
@ -0,0 +1,106 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type AccountUpdateTestSuite struct {
|
||||
AccountStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *AccountUpdateTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
func (suite *AccountUpdateTestSuite) SetupTest() {
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
|
||||
suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
|
||||
suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
}
|
||||
|
||||
func (suite *AccountUpdateTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
|
||||
|
||||
requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
|
||||
"display_name": "updated zork display name!!!",
|
||||
"locked": "true",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"]))
|
||||
ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
|
||||
suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have OK because our request was valid
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// 2. we should have no error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
fmt.Println(string(b))
|
||||
|
||||
// TODO write more assertions allee
|
||||
}
|
||||
|
||||
func TestAccountUpdateTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountUpdateTestSuite))
|
||||
}
|
@ -30,21 +30,19 @@ import (
|
||||
// It should be served as a GET at /api/v1/accounts/verify_credentials
|
||||
func (m *Module) AccountVerifyGETHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "accountVerifyGETHandler")
|
||||
authed, err := oauth.MustAuth(c, true, false, false, true)
|
||||
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, converting to mastosensitive...", authed.Account.ID)
|
||||
acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account)
|
||||
acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
l.Debugf("error getting account from processor: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
|
||||
c.JSON(http.StatusOK, acctSensitive)
|
||||
}
|
@ -16,4 +16,4 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
package account_test
|
@ -19,16 +19,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -42,19 +38,15 @@ const (
|
||||
// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
|
||||
type Module struct {
|
||||
config *config.Config
|
||||
db db.DB
|
||||
mediaHandler media.Handler
|
||||
mastoConverter mastotypes.Converter
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new admin module
|
||||
func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
config: config,
|
||||
db: db,
|
||||
mediaHandler: mediaHandler,
|
||||
mastoConverter: mastoConverter,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@ -64,25 +56,3 @@ func (m *Module) Route(r router.Router) error {
|
||||
r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables creates the necessary tables for this module in the given database
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
>smodel.Emoji{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -19,15 +19,13 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
@ -42,7 +40,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
// make sure we're authed with an admin account
|
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||
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()})
|
||||
@ -56,7 +54,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
|
||||
|
||||
// extract the media create form from the request context
|
||||
l.Tracef("parsing request form: %+v", c.Request.Form)
|
||||
form := &mastotypes.EmojiCreateRequest{}
|
||||
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)})
|
||||
@ -71,51 +69,17 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// open the emoji and extract the bytes from it
|
||||
f, err := form.Image.Open()
|
||||
mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form)
|
||||
if err != nil {
|
||||
l.Debugf("error opening emoji: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)})
|
||||
return
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
size, err := io.Copy(buf, f)
|
||||
if err != nil {
|
||||
l.Debugf("error reading emoji: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)})
|
||||
return
|
||||
}
|
||||
if size == 0 {
|
||||
l.Debug("could not read provided emoji: size 0 bytes")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"})
|
||||
return
|
||||
}
|
||||
|
||||
// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
|
||||
emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
|
||||
if err != nil {
|
||||
l.Debugf("error reading emoji: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji)
|
||||
if err != nil {
|
||||
l.Debugf("error converting emoji to mastotype: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.db.Put(emoji); err != nil {
|
||||
l.Debugf("database error while processing emoji: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)})
|
||||
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 *mastotypes.EmojiCreateRequest) error {
|
||||
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")
|
@ -19,15 +19,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -36,18 +33,16 @@ const BasePath = "/api/v1/apps"
|
||||
|
||||
// Module implements the ClientAPIModule interface for requests relating to registering/removing applications
|
||||
type Module struct {
|
||||
server oauth.Server
|
||||
db db.DB
|
||||
mastoConverter mastotypes.Converter
|
||||
config *config.Config
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new auth module
|
||||
func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
server: srv,
|
||||
db: db,
|
||||
mastoConverter: mastoConverter,
|
||||
config: config,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@ -57,21 +52,3 @@ func (m *Module) Route(s router.Router) error {
|
||||
s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables creates the necessary tables for this module in the given database
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
models := []interface{}{
|
||||
&oauth.Client{},
|
||||
&oauth.Token{},
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Application{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -16,6 +16,6 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app
|
||||
package app_test
|
||||
|
||||
// TODO: write tests
|
79
internal/api/client/app/appcreate.go
Normal file
79
internal/api/client/app/appcreate.go
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AppsPOSTHandler should be served at https://example.org/api/v1/apps
|
||||
// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
|
||||
func (m *Module) AppsPOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "AppsPOSTHandler")
|
||||
l.Trace("entering AppsPOSTHandler")
|
||||
|
||||
authed, err := oauth.Authed(c, false, false, false, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
form := &model.ApplicationCreateRequest{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// permitted length for most fields
|
||||
formFieldLen := 64
|
||||
// redirect can be a bit bigger because we probably need to encode data in the redirect uri
|
||||
formRedirectLen := 512
|
||||
|
||||
// check lengths of fields before proceeding so the user can't spam huge entries into the database
|
||||
if len(form.ClientName) > formFieldLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)})
|
||||
return
|
||||
}
|
||||
if len(form.Website) > formFieldLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)})
|
||||
return
|
||||
}
|
||||
if len(form.RedirectURIs) > formRedirectLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)})
|
||||
return
|
||||
}
|
||||
if len(form.Scopes) > formFieldLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)})
|
||||
return
|
||||
}
|
||||
|
||||
mastoApp, err := m.processor.AppCreate(authed, form)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
|
||||
c.JSON(http.StatusOK, mastoApp)
|
||||
}
|
@ -19,13 +19,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
@ -41,16 +40,18 @@ const (
|
||||
|
||||
// Module implements the ClientAPIModule interface for
|
||||
type Module struct {
|
||||
server oauth.Server
|
||||
config *config.Config
|
||||
db db.DB
|
||||
server oauth.Server
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new auth module
|
||||
func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
server: srv,
|
||||
config: config,
|
||||
db: db,
|
||||
server: server,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@ -68,21 +69,3 @@ func (m *Module) Route(s router.Router) error {
|
||||
s.AttachMiddleware(m.OauthTokenMiddleware)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables creates the necessary tables for this module in the given database
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
models := []interface{}{
|
||||
&oauth.Client{},
|
||||
&oauth.Token{},
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Application{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package auth
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -28,7 +28,7 @@ import (
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@ -103,7 +103,7 @@ func (suite *AuthTestSuite) SetupTest() {
|
||||
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
db, err := db.New(context.Background(), suite.config, log)
|
||||
db, err := db.NewPostgresService(context.Background(), suite.config, log)
|
||||
if err != nil {
|
||||
logrus.Panicf("error creating database connection: %s", err)
|
||||
}
|
@ -27,8 +27,8 @@ import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"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
|
||||
@ -178,7 +178,7 @@ func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
|
||||
s := sessions.Default(c)
|
||||
|
||||
// first make sure they've filled out the authorize form with the required values
|
||||
form := &mastotypes.OAuthAuthorize{}
|
||||
form := &model.OAuthAuthorize{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
return err
|
||||
}
|
@ -20,7 +20,7 @@ package auth
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
@ -30,7 +30,7 @@ import (
|
||||
// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow
|
||||
// public requests that don't have a Bearer token set (eg., for public instance information and so on).
|
||||
func (m *Module) OauthTokenMiddleware(c *gin.Context) {
|
||||
l := m.log.WithField("func", "ValidatePassword")
|
||||
l := m.log.WithField("func", "OauthTokenMiddleware")
|
||||
l.Trace("entering OauthTokenMiddleware")
|
||||
|
||||
ti, err := m.server.ValidationBearerToken(c.Request)
|
@ -24,7 +24,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
@ -23,12 +23,12 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -46,18 +46,16 @@ const (
|
||||
// The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
|
||||
type FileServer struct {
|
||||
config *config.Config
|
||||
db db.DB
|
||||
storage storage.Storage
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
storageBase string
|
||||
}
|
||||
|
||||
// New returns a new fileServer module
|
||||
func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &FileServer{
|
||||
config: config,
|
||||
db: db,
|
||||
storage: storage,
|
||||
processor: processor,
|
||||
log: log,
|
||||
storageBase: config.StorageConfig.ServeBasePath,
|
||||
}
|
94
internal/api/client/fileserver/servefile.go
Normal file
94
internal/api/client/fileserver/servefile.go
Normal file
@ -0,0 +1,94 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package fileserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
|
||||
//
|
||||
// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
|
||||
// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
|
||||
func (m *FileServer) ServeFile(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "ServeFile",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Trace("received request")
|
||||
|
||||
authed, err := oauth.Authed(c, false, false, false, false)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
|
||||
// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
|
||||
// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
|
||||
accountID := c.Param(AccountIDKey)
|
||||
if accountID == "" {
|
||||
l.Debug("missing accountID from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
mediaType := c.Param(MediaTypeKey)
|
||||
if mediaType == "" {
|
||||
l.Debug("missing mediaType from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
mediaSize := c.Param(MediaSizeKey)
|
||||
if mediaSize == "" {
|
||||
l.Debug("missing mediaSize from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
fileName := c.Param(FileNameKey)
|
||||
if fileName == "" {
|
||||
l.Debug("missing fileName from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{
|
||||
AccountID: accountID,
|
||||
MediaType: mediaType,
|
||||
MediaSize: mediaSize,
|
||||
FileName: fileName,
|
||||
})
|
||||
if err != nil {
|
||||
l.Debug(err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil)
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package test
|
||||
package fileserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -30,14 +30,16 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/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"
|
||||
)
|
||||
|
||||
@ -48,7 +50,9 @@ type ServeFileTestSuite struct {
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
federator federation.Federator
|
||||
tc typeutils.TypeConverter
|
||||
processor message.Processor
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
|
||||
@ -74,12 +78,14 @@ func (suite *ServeFileTestSuite) SetupSuite() {
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
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.db, suite.storage, suite.log).(*fileserver.FileServer)
|
||||
suite.fileServer = fileserver.New(suite.config, suite.processor, suite.log).(*fileserver.FileServer)
|
||||
}
|
||||
|
||||
func (suite *ServeFileTestSuite) TearDownSuite() {
|
||||
@ -126,11 +132,11 @@ func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
|
||||
},
|
||||
gin.Param{
|
||||
Key: fileserver.MediaTypeKey,
|
||||
Value: media.MediaAttachment,
|
||||
Value: string(media.Attachment),
|
||||
},
|
||||
gin.Param{
|
||||
Key: fileserver.MediaSizeKey,
|
||||
Value: media.MediaOriginal,
|
||||
Value: string(media.Original),
|
||||
},
|
||||
gin.Param{
|
||||
Key: fileserver.FileNameKey,
|
@ -23,12 +23,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -37,20 +36,16 @@ const BasePath = "/api/v1/media"
|
||||
|
||||
// Module implements the ClientAPIModule interface for media
|
||||
type Module struct {
|
||||
mediaHandler media.Handler
|
||||
config *config.Config
|
||||
db db.DB
|
||||
mastoConverter mastotypes.Converter
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new auth module
|
||||
func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
mediaHandler: mediaHandler,
|
||||
config: config,
|
||||
db: db,
|
||||
mastoConverter: mastoConverter,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
91
internal/api/client/media/mediacreate.go
Normal file
91
internal/api/client/media/mediacreate.go
Normal file
@ -0,0 +1,91 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// MediaCreatePOSTHandler handles requests to create/upload media attachments
|
||||
func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||
authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything*
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// extract the media create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||
form := &model.AttachmentRequest{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||
return
|
||||
}
|
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form)
|
||||
if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
|
||||
l.Debugf("error validating form: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
mastoAttachment, err := m.processor.MediaCreate(authed, form)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusAccepted, mastoAttachment)
|
||||
}
|
||||
|
||||
func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error {
|
||||
// check there actually is a file attached and it's not size 0
|
||||
if form.File == nil || form.File.Size == 0 {
|
||||
return errors.New("no attachment given")
|
||||
}
|
||||
|
||||
// a very superficial check to see if no size limits are exceeded
|
||||
// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
|
||||
maxSize := config.MaxVideoSize
|
||||
if config.MaxImageSize > maxSize {
|
||||
maxSize = config.MaxImageSize
|
||||
}
|
||||
if form.File.Size > int64(maxSize) {
|
||||
return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
|
||||
}
|
||||
|
||||
if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
|
||||
return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
|
||||
}
|
||||
|
||||
// TODO: validate focus here
|
||||
|
||||
return nil
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package test
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -32,15 +32,17 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
|
||||
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/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"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"
|
||||
)
|
||||
|
||||
@ -51,9 +53,11 @@ type MediaCreateTestSuite struct {
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
federator federation.Federator
|
||||
tc typeutils.TypeConverter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
processor message.Processor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
@ -77,12 +81,14 @@ func (suite *MediaCreateTestSuite) SetupSuite() {
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
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.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module)
|
||||
suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module)
|
||||
}
|
||||
|
||||
func (suite *MediaCreateTestSuite) TearDownSuite() {
|
||||
@ -158,26 +164,26 @@ func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful()
|
||||
assert.NoError(suite.T(), err)
|
||||
fmt.Println(string(b))
|
||||
|
||||
attachmentReply := &mastomodel.Attachment{}
|
||||
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(), mastomodel.MediaMeta{
|
||||
Original: mastomodel.MediaDimensions{
|
||||
assert.EqualValues(suite.T(), model.MediaMeta{
|
||||
Original: model.MediaDimensions{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Size: "1920x1080",
|
||||
Aspect: 1.7777778,
|
||||
},
|
||||
Small: mastomodel.MediaDimensions{
|
||||
Small: model.MediaDimensions{
|
||||
Width: 256,
|
||||
Height: 144,
|
||||
Size: "256x144",
|
||||
Aspect: 1.7777778,
|
||||
},
|
||||
Focus: mastomodel.MediaFocus{
|
||||
Focus: model.MediaFocus{
|
||||
X: -0.5,
|
||||
Y: 0.5,
|
||||
},
|
@ -19,19 +19,14 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -80,21 +75,15 @@ const (
|
||||
// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
|
||||
type Module struct {
|
||||
config *config.Config
|
||||
db db.DB
|
||||
mediaHandler media.Handler
|
||||
mastoConverter mastotypes.Converter
|
||||
distributor distributor.Distributor
|
||||
processor message.Processor
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
// New returns a new account module
|
||||
func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
config: config,
|
||||
db: db,
|
||||
mediaHandler: mediaHandler,
|
||||
mastoConverter: mastoConverter,
|
||||
distributor: distributor,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@ -105,41 +94,12 @@ func (m *Module) Route(r router.Router) error {
|
||||
r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
|
||||
|
||||
r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
|
||||
r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
|
||||
r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
|
||||
|
||||
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables populates necessary tables in the given DB
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Block{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.StatusFave{},
|
||||
>smodel.StatusBookmark{},
|
||||
>smodel.StatusMute{},
|
||||
>smodel.StatusPin{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
>smodel.Emoji{},
|
||||
>smodel.Tag{},
|
||||
>smodel.Mention{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
if err := db.CreateTable(m); err != nil {
|
||||
return fmt.Errorf("error creating table: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// muxHandler is a little workaround to overcome the limitations of Gin
|
||||
func (m *Module) muxHandler(c *gin.Context) {
|
||||
m.log.Debug("entering mux handler")
|
58
internal/api/client/status/status_test.go
Normal file
58
internal/api/client/status/status_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/message"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
)
|
||||
|
||||
// nolint
|
||||
type StatusStandardTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
tc typeutils.TypeConverter
|
||||
federator federation.Federator
|
||||
processor message.Processor
|
||||
storage storage.Storage
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
}
|
130
internal/api/client/status/statuscreate.go
Normal file
130
internal/api/client/status/statuscreate.go
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// StatusCreatePOSTHandler deals with the creation of new statuses
|
||||
func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||
authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// First check this user/account is permitted to post new statuses.
|
||||
// There's no point continuing otherwise.
|
||||
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
|
||||
return
|
||||
}
|
||||
|
||||
// extract the status create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||
form := &model.AdvancedStatusCreateForm{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||
return
|
||||
}
|
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form)
|
||||
if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
|
||||
l.Debugf("error validating form: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
mastoStatus, err := m.processor.StatusCreate(authed, form)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status create: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
||||
|
||||
func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error {
|
||||
// validate that, structurally, we have a valid status/post
|
||||
if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
|
||||
return errors.New("no status, media, or poll provided")
|
||||
}
|
||||
|
||||
if form.MediaIDs != nil && form.Poll != nil {
|
||||
return errors.New("can't post media + poll in same status")
|
||||
}
|
||||
|
||||
// validate status
|
||||
if form.Status != "" {
|
||||
if len(form.Status) > config.MaxChars {
|
||||
return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// validate media attachments
|
||||
if len(form.MediaIDs) > config.MaxMediaFiles {
|
||||
return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
|
||||
}
|
||||
|
||||
// validate poll
|
||||
if form.Poll != nil {
|
||||
if form.Poll.Options == nil {
|
||||
return errors.New("poll with no options")
|
||||
}
|
||||
if len(form.Poll.Options) > config.PollMaxOptions {
|
||||
return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
|
||||
}
|
||||
for _, p := range form.Poll.Options {
|
||||
if len(p) > config.PollOptionMaxChars {
|
||||
return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validate spoiler text/cw
|
||||
if form.SpoilerText != "" {
|
||||
if len(form.SpoilerText) > config.CWMaxChars {
|
||||
return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// validate post language
|
||||
if form.Language != "" {
|
||||
if err := util.ValidateLanguage(form.Language); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -28,95 +28,46 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/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/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusCreateTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusCreateTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusCreateTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusCreateTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
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")
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *StatusCreateTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
/*
|
||||
TESTING: StatusCreatePOSTHandler
|
||||
*/
|
||||
|
||||
// Post a new status with some custom visibility settings
|
||||
func (suite *StatusCreateTestSuite) TestPostNewStatus() {
|
||||
|
||||
@ -152,16 +103,16 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPrivate, statusReply.Visibility)
|
||||
assert.Equal(suite.T(), model.VisibilityPrivate, statusReply.Visibility)
|
||||
assert.Len(suite.T(), statusReply.Tags, 1)
|
||||
assert.Equal(suite.T(), mastomodel.Tag{
|
||||
assert.Equal(suite.T(), model.Tag{
|
||||
Name: "helloworld",
|
||||
URL: "http://localhost:8080/tags/helloworld",
|
||||
}, statusReply.Tags[0])
|
||||
@ -197,7 +148,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
statusReply := &model.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
@ -241,7 +192,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b))
|
||||
assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
|
||||
}
|
||||
|
||||
// Post a reply to the status of a local user that allows replies.
|
||||
@ -271,14 +222,14 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
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)
|
||||
@ -313,14 +264,14 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
|
||||
|
||||
fmt.Println(string(b))
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
|
||||
|
||||
// there should be one media attachment
|
||||
assert.Len(suite.T(), statusReply.MediaAttachments, 1)
|
||||
@ -331,7 +282,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// convert it to a masto attachment
|
||||
gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment)
|
||||
gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// compare it with what we have now
|
60
internal/api/client/status/statusdelete.go
Normal file
60
internal/api/client/status/statusdelete.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusDELETEHandler verifies and handles deletion of a status
|
||||
func (m *Module) StatusDELETEHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusDELETEHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't delete status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status delete: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
60
internal/api/client/status/statusfave.go
Normal file
60
internal/api/client/status/statusfave.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusFavePOSTHandler handles fave requests against a given status ID
|
||||
func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusFavePOSTHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't fave status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
mastoStatus, err := m.processor.StatusFave(authed, targetStatusID)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status fave: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -28,75 +28,19 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusFaveTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusFaveTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusFaveTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusFaveTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
@ -106,16 +50,23 @@ func (suite *StatusFaveTestSuite) SetupTest() {
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
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)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
// fave a status
|
||||
func (suite *StatusFaveTestSuite) TestPostFave() {
|
||||
|
||||
@ -152,14 +103,14 @@ func (suite *StatusFaveTestSuite) TestPostFave() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
|
||||
assert.True(suite.T(), statusReply.Favourited)
|
||||
assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
|
||||
}
|
||||
@ -193,13 +144,13 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
|
||||
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses
|
||||
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(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b))
|
||||
assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
|
||||
}
|
||||
|
||||
func TestStatusFaveTestSuite(t *testing.T) {
|
60
internal/api/client/status/statusfavedby.go
Normal file
60
internal/api/client/status/statusfavedby.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
|
||||
func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "statusGETHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Errorf("error authing status faved by request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status faved by request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoAccounts)
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -28,71 +28,19 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusFavedByTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusFavedByTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusFavedByTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusFavedByTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
@ -102,16 +50,23 @@ func (suite *StatusFavedByTestSuite) SetupTest() {
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
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)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
|
||||
t := suite.testTokens["local_account_2"]
|
||||
oauthToken := oauth.TokenToOauthToken(t)
|
||||
@ -146,7 +101,7 @@ func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
accts := []mastomodel.Account{}
|
||||
accts := []model.Account{}
|
||||
err = json.Unmarshal(b, &accts)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
60
internal/api/client/status/statusget.go
Normal file
60
internal/api/client/status/statusget.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusGETHandler is for handling requests to just get one status based on its ID
|
||||
func (m *Module) StatusGETHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "statusGETHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Errorf("error authing status faved by request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
mastoStatus, err := m.processor.StatusGet(authed, targetStatusID)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status get: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -16,98 +16,47 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusGetTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusGetTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusGetTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusGetTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
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")
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *StatusGetTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
/*
|
||||
TESTING: StatusGetPOSTHandler
|
||||
*/
|
||||
|
||||
// Post a new status with some custom visibility settings
|
||||
func (suite *StatusGetTestSuite) TestPostNewStatus() {
|
||||
|
||||
@ -143,16 +92,16 @@ func (suite *StatusGetTestSuite) TestPostNewStatus() {
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// statusReply := &mastomodel.Status{}
|
||||
// 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(), mastomodel.VisibilityPrivate, statusReply.Visibility)
|
||||
// assert.Equal(suite.T(), mastotypes.VisibilityPrivate, statusReply.Visibility)
|
||||
// assert.Len(suite.T(), statusReply.Tags, 1)
|
||||
// assert.Equal(suite.T(), mastomodel.Tag{
|
||||
// assert.Equal(suite.T(), mastotypes.Tag{
|
||||
// Name: "helloworld",
|
||||
// URL: "http://localhost:8080/tags/helloworld",
|
||||
// }, statusReply.Tags[0])
|
60
internal/api/client/status/statusunfave.go
Normal file
60
internal/api/client/status/statusunfave.go
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
|
||||
func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusUnfavePOSTHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't unfave status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID)
|
||||
if err != nil {
|
||||
l.Debugf("error processing status unfave: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
package status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -28,75 +28,19 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusUnfaveTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusUnfaveTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusUnfaveTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusUnfaveTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
@ -106,16 +50,23 @@ func (suite *StatusUnfaveTestSuite) SetupTest() {
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
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)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
// unfave a status
|
||||
func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
|
||||
|
||||
@ -153,14 +104,14 @@ func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
|
||||
assert.False(suite.T(), statusReply.Favourited)
|
||||
assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
|
||||
}
|
||||
@ -202,14 +153,14 @@ func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
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(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
|
||||
assert.False(suite.T(), statusReply.Favourited)
|
||||
assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
|
||||
}
|
@ -16,9 +16,12 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
import "mime/multipart"
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/
|
||||
type Account struct {
|
||||
@ -86,6 +89,8 @@ type AccountCreateRequest struct {
|
||||
Agreement bool `form:"agreement" binding:"required"`
|
||||
// The language of the confirmation email that will be sent
|
||||
Locale string `form:"locale" binding:"required"`
|
||||
// The IP of the sign up request, will not be parsed from the form but must be added manually
|
||||
IP net.IP `form:"-"`
|
||||
}
|
||||
|
||||
// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials.
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/
|
||||
type Activity struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/
|
||||
type AdminAccountInfo struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/
|
||||
type Announcement struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/
|
||||
type AnnouncementReaction struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/.
|
||||
// Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user.
|
||||
@ -38,10 +38,10 @@ type Application struct {
|
||||
VapidKey string `json:"vapid_key,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps.
|
||||
// ApplicationCreateRequest represents a POST request to https://example.org/api/v1/apps.
|
||||
// See here: https://docs.joinmastodon.org/methods/apps/
|
||||
// And here: https://docs.joinmastodon.org/client/token/
|
||||
type ApplicationPOSTRequest struct {
|
||||
type ApplicationCreateRequest struct {
|
||||
// A name for your application
|
||||
ClientName string `form:"client_name" binding:"required"`
|
||||
// Where the user should be redirected after authorization.
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
import "mime/multipart"
|
||||
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/
|
||||
type Card struct {
|
41
internal/api/model/content.go
Normal file
41
internal/api/model/content.go
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
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 model
|
||||
|
||||
// Content wraps everything needed to serve a blob of content (some kind of media) through the API.
|
||||
type Content struct {
|
||||
// MIME content type
|
||||
ContentType string
|
||||
// ContentLength in bytes
|
||||
ContentLength int64
|
||||
// Actual content blob
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// GetContentRequestForm describes a piece of content desired by the caller of the fileserver API.
|
||||
type GetContentRequestForm struct {
|
||||
// AccountID of the content owner
|
||||
AccountID string
|
||||
// MediaType of the content (should be convertible to a media.MediaType)
|
||||
MediaType string
|
||||
// MediaSize of the content (should be convertible to a media.MediaSize)
|
||||
MediaSize string
|
||||
// Filename of the content
|
||||
FileName string
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/
|
||||
type Context struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/
|
||||
type Conversation struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
import "mime/multipart"
|
||||
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/
|
||||
type Error struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/
|
||||
type FeaturedTag struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/
|
||||
type Field struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/
|
||||
// If whole_word is true , client app should do:
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/
|
||||
type History struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/
|
||||
type IdentityProof struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
|
||||
type Instance struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/
|
||||
type List struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/
|
||||
type Marker struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/
|
||||
type Mention struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/
|
||||
type Notification struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize
|
||||
// See here: https://docs.joinmastodon.org/methods/apps/oauth/
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/
|
||||
type Poll struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/
|
||||
type Preferences struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/
|
||||
type PushSubscription struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/
|
||||
type Relationship struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/
|
||||
type Results struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/
|
||||
type ScheduledStatus struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Source represents display or publishing preferences of user's own account.
|
||||
// Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/
|
||||
type Status struct {
|
||||
@ -118,3 +118,21 @@ const (
|
||||
// VisibilityDirect means visible only to tagged recipients
|
||||
VisibilityDirect Visibility = "direct"
|
||||
)
|
||||
|
||||
type AdvancedStatusCreateForm struct {
|
||||
StatusCreateRequest
|
||||
AdvancedVisibilityFlagsForm
|
||||
}
|
||||
|
||||
type AdvancedVisibilityFlagsForm struct {
|
||||
// The gotosocial visibility model
|
||||
VisibilityAdvanced *string `form:"visibility_advanced"`
|
||||
// This status will be federated beyond the local timeline(s)
|
||||
Federated *bool `form:"federated"`
|
||||
// This status can be boosted/reblogged
|
||||
Boostable *bool `form:"boostable"`
|
||||
// This status can be replied to
|
||||
Replyable *bool `form:"replyable"`
|
||||
// This status can be liked/faved
|
||||
Likeable *bool `form:"likeable"`
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/
|
||||
type Tag struct {
|
@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package mastotypes
|
||||
package model
|
||||
|
||||
// Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/
|
||||
type Token struct {
|
70
internal/api/s2s/user/user.go
Normal file
70
internal/api/s2s/user/user.go
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
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 user
|
||||
|
||||
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"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// UsernameKey is for account usernames.
|
||||
UsernameKey = "username"
|
||||
// UsersBasePath is the base path for serving information about Users eg https://example.org/users
|
||||
UsersBasePath = "/" + util.UsersPath
|
||||
// UsersBasePathWithUsername is just the users base path with the Username key in it.
|
||||
// Use this anywhere you need to know the username of the user being queried.
|
||||
// Eg https://example.org/users/:username
|
||||
UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey
|
||||
)
|
||||
|
||||
// ActivityPubAcceptHeaders represents the Accept headers mentioned here:
|
||||
// https://www.w3.org/TR/activitypub/#retrieving-objects
|
||||
var ActivityPubAcceptHeaders = []string{
|
||||
`application/activity+json`,
|
||||
`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
|
||||
}
|
||||
|
||||
// Module implements the FederationAPIModule interface
|
||||
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.FederationModule {
|
||||
return &Module{
|
||||
config: config,
|
||||
processor: processor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Route satisfies the RESTAPIModule interface
|
||||
func (m *Module) Route(s router.Router) error {
|
||||
s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler)
|
||||
return nil
|
||||
}
|
40
internal/api/s2s/user/user_test.go
Normal file
40
internal/api/s2s/user/user_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
|
||||
"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 UserStandardTestSuite 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
|
||||
userModule *user.Module
|
||||
}
|
67
internal/api/s2s/user/userget.go
Normal file
67
internal/api/s2s/user/userget.go
Normal file
@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UsersGETHandler should be served at https://example.org/users/:username.
|
||||
//
|
||||
// The goal here is to return the activitypub representation of an account
|
||||
// in the form of a vocab.ActivityStreamsPerson. This should only be served
|
||||
// to REMOTE SERVERS that present a valid signature on the GET request, on
|
||||
// behalf of a user, otherwise we risk leaking information about users publicly.
|
||||
//
|
||||
// And of course, the request should be refused if the account or server making the
|
||||
// request is blocked.
|
||||
func (m *Module) UsersGETHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "UsersGETHandler",
|
||||
"url": c.Request.RequestURI,
|
||||
})
|
||||
|
||||
requestedUsername := c.Param(UsernameKey)
|
||||
if requestedUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
|
||||
return
|
||||
}
|
||||
|
||||
// make sure this actually an AP request
|
||||
format := c.NegotiateFormat(ActivityPubAcceptHeaders...)
|
||||
if format == "" {
|
||||
c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"})
|
||||
return
|
||||
}
|
||||
l.Tracef("negotiated format: %s", format)
|
||||
|
||||
// make a copy of the context to pass along so we don't break anything
|
||||
cp := c.Copy()
|
||||
user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well
|
||||
if err != nil {
|
||||
l.Info(err.Error())
|
||||
c.JSON(err.Code(), gin.H{"error": err.Safe()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
155
internal/api/s2s/user/userget_test.go
Normal file
155
internal/api/s2s/user/userget_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type UserGetTestSuite struct {
|
||||
UserStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *UserGetTestSuite) 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 *UserGetTestSuite) SetupTest() {
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.tc = testrig.NewTestTypeConverter(suite.db)
|
||||
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.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module)
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
}
|
||||
|
||||
func (suite *UserGetTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *UserGetTestSuite) TestGetUser() {
|
||||
// the dereference we're gonna use
|
||||
signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"]
|
||||
|
||||
requestingAccount := suite.testAccounts["remote_account_1"]
|
||||
targetAccount := suite.testAccounts["local_account_1"]
|
||||
|
||||
encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey)
|
||||
assert.NoError(suite.T(), err)
|
||||
publicKeyBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: encodedPublicKey,
|
||||
})
|
||||
publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n")
|
||||
|
||||
// for this test we need the client to return the public key of the requester on the 'remote' instance
|
||||
responseBodyString := fmt.Sprintf(`
|
||||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1"
|
||||
],
|
||||
|
||||
"id": "%s",
|
||||
"type": "Person",
|
||||
"preferredUsername": "%s",
|
||||
"inbox": "%s",
|
||||
|
||||
"publicKey": {
|
||||
"id": "%s",
|
||||
"owner": "%s",
|
||||
"publicKeyPem": "%s"
|
||||
}
|
||||
}`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString)
|
||||
|
||||
// create a transport controller whose client will just return the response body string we specified above
|
||||
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
|
||||
r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString)))
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: r,
|
||||
}, nil
|
||||
}))
|
||||
// get this transport controller embedded right in the user module we're testing
|
||||
federator := testrig.NewTestFederator(suite.db, tc)
|
||||
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
|
||||
userModule := user.New(suite.config, processor, suite.log).(*user.Module)
|
||||
|
||||
// setup request
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 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: user.UsernameKey,
|
||||
Value: targetAccount.Username,
|
||||
},
|
||||
}
|
||||
|
||||
// we need these headers for the request to be validated
|
||||
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
|
||||
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
|
||||
ctx.Request.Header.Set("Digest", signedRequest.DigestHeader)
|
||||
|
||||
// trigger the function being tested
|
||||
userModule.UsersGETHandler(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)
|
||||
|
||||
// should be a Person
|
||||
m := make(map[string]interface{})
|
||||
err = json.Unmarshal(b, &m)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
t, err := streams.ToType(context.Background(), m)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
person, ok := t.(vocab.ActivityStreamsPerson)
|
||||
assert.True(suite.T(), ok)
|
||||
|
||||
// convert person to account
|
||||
// since this account is already known, we should get a pretty full model of it from the conversion
|
||||
a, err := suite.tc.ASRepresentationToAccount(person)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.EqualValues(suite.T(), targetAccount.Username, a.Username)
|
||||
}
|
||||
|
||||
func TestUserGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(UserGetTestSuite))
|
||||
}
|
@ -20,9 +20,8 @@ package security
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
@ -33,7 +32,7 @@ type Module struct {
|
||||
}
|
||||
|
||||
// New returns a new security module
|
||||
func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||
func New(config *config.Config, log *logrus.Logger) api.ClientModule {
|
||||
return &Module{
|
||||
config: config,
|
||||
log: log,
|
||||
@ -45,8 +44,3 @@ func (m *Module) Route(s router.Router) error {
|
||||
s.AttachMiddleware(m.FlocBlock)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface
|
||||
func (m *Module) CreateTables(db db.DB) error {
|
||||
return nil
|
||||
}
|
@ -1,260 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
|
||||
// It should be served as a PATCH at /api/v1/accounts/update_credentials
|
||||
//
|
||||
// TODO: this can be optimized massively by building up a picture of what we want the new account
|
||||
// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
|
||||
// which is not gonna make the database very happy when lots of requests are going through.
|
||||
// This way it would also be safer because the update won't happen until *all* the fields are validated.
|
||||
// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
|
||||
func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
|
||||
authed, err := oauth.MustAuth(c, true, false, false, true)
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.Tracef("retrieved account %+v", authed.Account.ID)
|
||||
|
||||
l.Trace("parsing request form")
|
||||
form := &mastotypes.UpdateCredentialsRequest{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// if everything on the form is nil, then nothing has been set and we shouldn't continue
|
||||
if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
|
||||
l.Debugf("could not parse form from request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
|
||||
return
|
||||
}
|
||||
|
||||
if form.Discoverable != nil {
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil {
|
||||
l.Debugf("error updating discoverable: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Bot != nil {
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil {
|
||||
l.Debugf("error updating bot: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.DisplayName != nil {
|
||||
if err := util.ValidateDisplayName(*form.DisplayName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Note != nil {
|
||||
if err := util.ValidateNote(*form.Note); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil {
|
||||
l.Debugf("error updating note: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Avatar != nil && form.Avatar.Size != 0 {
|
||||
avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo)
|
||||
}
|
||||
|
||||
if form.Header != nil && form.Header.Size != 0 {
|
||||
headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Debugf("could not update header for account %s: %s", authed.Account.ID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo)
|
||||
}
|
||||
|
||||
if form.Locked != nil {
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Source != nil {
|
||||
if form.Source.Language != nil {
|
||||
if err := util.ValidateLanguage(*form.Source.Language); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Source.Sensitive != nil {
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if form.Source.Privacy != nil {
|
||||
if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if form.FieldsAttributes != nil {
|
||||
// // TODO: parse fields attributes nicely and update
|
||||
// }
|
||||
|
||||
// fetch the account with all updated values set
|
||||
updatedAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
|
||||
l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount)
|
||||
if err != nil {
|
||||
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
|
||||
c.JSON(http.StatusOK, acctSensitive)
|
||||
}
|
||||
|
||||
/*
|
||||
HELPER FUNCTIONS
|
||||
*/
|
||||
|
||||
// TODO: try to combine the below two functions because this is a lot of code repetition.
|
||||
|
||||
// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form,
|
||||
// parsing and checking the image, and doing the necessary updates in the database for this to become
|
||||
// the account's new avatar image.
|
||||
func (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
|
||||
var err error
|
||||
if int(avatar.Size) > m.config.MediaConfig.MaxImageSize {
|
||||
err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize)
|
||||
return nil, err
|
||||
}
|
||||
f, err := avatar.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read provided avatar: %s", err)
|
||||
}
|
||||
|
||||
// extract the bytes
|
||||
buf := new(bytes.Buffer)
|
||||
size, err := io.Copy(buf, f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read provided avatar: %s", err)
|
||||
}
|
||||
if size == 0 {
|
||||
return nil, errors.New("could not read provided avatar: size 0 bytes")
|
||||
}
|
||||
|
||||
// do the setting
|
||||
avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error processing avatar: %s", err)
|
||||
}
|
||||
|
||||
return avatarInfo, f.Close()
|
||||
}
|
||||
|
||||
// UpdateAccountHeader does the dirty work of checking the header part of an account update form,
|
||||
// parsing and checking the image, and doing the necessary updates in the database for this to become
|
||||
// the account's new header image.
|
||||
func (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
|
||||
var err error
|
||||
if int(header.Size) > m.config.MediaConfig.MaxImageSize {
|
||||
err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize)
|
||||
return nil, err
|
||||
}
|
||||
f, err := header.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read provided header: %s", err)
|
||||
}
|
||||
|
||||
// extract the bytes
|
||||
buf := new(bytes.Buffer)
|
||||
size, err := io.Copy(buf, f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read provided header: %s", err)
|
||||
}
|
||||
if size == 0 {
|
||||
return nil, errors.New("could not read provided header: size 0 bytes")
|
||||
}
|
||||
|
||||
// do the setting
|
||||
headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error processing header: %s", err)
|
||||
}
|
||||
|
||||
return headerInfo, f.Close()
|
||||
}
|
@ -1,551 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/oauth2/v4"
|
||||
"github.com/superseriousbusiness/oauth2/v4/models"
|
||||
oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AccountCreateTestSuite struct {
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
log *logrus.Logger
|
||||
testAccountLocal *gtsmodel.Account
|
||||
testApplication *gtsmodel.Application
|
||||
testToken oauth2.TokenInfo
|
||||
mockOauthServer *oauth.MockServer
|
||||
mockStorage *storage.MockStorage
|
||||
mediaHandler media.Handler
|
||||
mastoConverter mastotypes.Converter
|
||||
db db.DB
|
||||
accountModule *account.Module
|
||||
newUserFormHappyPath url.Values
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *AccountCreateTestSuite) SetupSuite() {
|
||||
// some of our subsequent entities need a log so create this here
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
suite.log = log
|
||||
|
||||
suite.testAccountLocal = >smodel.Account{
|
||||
ID: uuid.NewString(),
|
||||
Username: "test_user",
|
||||
}
|
||||
|
||||
// can use this test application throughout
|
||||
suite.testApplication = >smodel.Application{
|
||||
ID: "weeweeeeeeeeeeeeee",
|
||||
Name: "a test application",
|
||||
Website: "https://some-application-website.com",
|
||||
RedirectURI: "http://localhost:8080",
|
||||
ClientID: "a-known-client-id",
|
||||
ClientSecret: "some-secret",
|
||||
Scopes: "read",
|
||||
VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
|
||||
}
|
||||
|
||||
// can use this test token throughout
|
||||
suite.testToken = &oauthmodels.Token{
|
||||
ClientID: "a-known-client-id",
|
||||
RedirectURI: "http://localhost:8080",
|
||||
Scope: "read",
|
||||
Code: "123456789",
|
||||
CodeCreateAt: time.Now(),
|
||||
CodeExpiresIn: time.Duration(10 * time.Minute),
|
||||
}
|
||||
|
||||
// Direct config to local postgres instance
|
||||
c := config.Empty()
|
||||
c.Protocol = "http"
|
||||
c.Host = "localhost"
|
||||
c.DBConfig = &config.DBConfig{
|
||||
Type: "postgres",
|
||||
Address: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "postgres",
|
||||
Database: "postgres",
|
||||
ApplicationName: "gotosocial",
|
||||
}
|
||||
c.MediaConfig = &config.MediaConfig{
|
||||
MaxImageSize: 2 << 20,
|
||||
}
|
||||
c.StorageConfig = &config.StorageConfig{
|
||||
Backend: "local",
|
||||
BasePath: "/tmp",
|
||||
ServeProtocol: "http",
|
||||
ServeHost: "localhost",
|
||||
ServeBasePath: "/fileserver/media",
|
||||
}
|
||||
suite.config = c
|
||||
|
||||
// use an actual database for this, because it's just easier than mocking one out
|
||||
database, err := db.New(context.Background(), c, log)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.db = database
|
||||
|
||||
// we need to mock the oauth server because account creation needs it to create a new token
|
||||
suite.mockOauthServer = &oauth.MockServer{}
|
||||
suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
|
||||
l := suite.log.WithField("func", "GenerateUserAccessToken")
|
||||
token := args.Get(0).(oauth2.TokenInfo)
|
||||
l.Infof("received token %+v", token)
|
||||
clientSecret := args.Get(1).(string)
|
||||
l.Infof("received clientSecret %+v", clientSecret)
|
||||
userID := args.Get(2).(string)
|
||||
l.Infof("received userID %+v", userID)
|
||||
}).Return(&models.Token{
|
||||
Access: "we're authorized now!",
|
||||
}, nil)
|
||||
|
||||
suite.mockStorage = &storage.MockStorage{}
|
||||
// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
|
||||
suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
|
||||
|
||||
// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
|
||||
suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
|
||||
|
||||
suite.mastoConverter = mastotypes.New(suite.config, suite.db)
|
||||
|
||||
// and finally here's the thing we're actually testing!
|
||||
suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
|
||||
}
|
||||
|
||||
func (suite *AccountCreateTestSuite) TearDownSuite() {
|
||||
if err := suite.db.Stop(context.Background()); err != nil {
|
||||
logrus.Panicf("error closing db connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTest creates a db connection and creates necessary tables before each test
|
||||
func (suite *AccountCreateTestSuite) SetupTest() {
|
||||
// create all the tables we might need in thie suite
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
for _, m := range models {
|
||||
if err := suite.db.CreateTable(m); err != nil {
|
||||
logrus.Panicf("db connection error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
|
||||
suite.newUserFormHappyPath = url.Values{
|
||||
"reason": []string{"a very good reason that's at least 40 characters i swear"},
|
||||
"username": []string{"test_user"},
|
||||
"email": []string{"user@example.org"},
|
||||
"password": []string{"very-strong-password"},
|
||||
"agreement": []string{"true"},
|
||||
"locale": []string{"en"},
|
||||
}
|
||||
|
||||
// same with accounts config
|
||||
suite.config.AccountsConfig = &config.AccountsConfig{
|
||||
OpenRegistration: true,
|
||||
RequireApproval: true,
|
||||
ReasonRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *AccountCreateTestSuite) TearDownTest() {
|
||||
|
||||
// remove all the tables we might have used so it's clear for the next test
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
for _, m := range models {
|
||||
if err := suite.db.DropTable(m); err != nil {
|
||||
logrus.Panicf("error dropping table: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
/*
|
||||
TESTING: AccountCreatePOSTHandler
|
||||
*/
|
||||
|
||||
// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
|
||||
// and at the end of it a new user and account should be added into the database.
|
||||
//
|
||||
// This is the handler served at /api/v1/accounts as POST
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have OK from our call to the function
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// 2. we should have a token in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
t := &mastomodel.Token{}
|
||||
err = json.Unmarshal(b, t)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
|
||||
|
||||
// check new account
|
||||
|
||||
// 1. we should be able to get the new account from the db
|
||||
acct := >smodel.Account{}
|
||||
err = suite.db.GetWhere("username", "test_user", acct)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.NotNil(suite.T(), acct)
|
||||
// 2. reason should be set
|
||||
assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
|
||||
// 3. display name should be equal to username by default
|
||||
assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
|
||||
// 4. domain should be nil because this is a local account
|
||||
assert.Nil(suite.T(), nil, acct.Domain)
|
||||
// 5. id should be set and parseable as a uuid
|
||||
assert.NotNil(suite.T(), acct.ID)
|
||||
_, err = uuid.Parse(acct.ID)
|
||||
assert.Nil(suite.T(), err)
|
||||
// 6. private and public key should be set
|
||||
assert.NotNil(suite.T(), acct.PrivateKey)
|
||||
assert.NotNil(suite.T(), acct.PublicKey)
|
||||
|
||||
// check new user
|
||||
|
||||
// 1. we should be able to get the new user from the db
|
||||
usr := >smodel.User{}
|
||||
err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), usr)
|
||||
|
||||
// 2. user should have account id set to account we got above
|
||||
assert.Equal(suite.T(), acct.ID, usr.AccountID)
|
||||
|
||||
// 3. id should be set and parseable as a uuid
|
||||
assert.NotNil(suite.T(), usr.ID)
|
||||
_, err = uuid.Parse(usr.ID)
|
||||
assert.Nil(suite.T(), err)
|
||||
|
||||
// 4. locale should be equal to what we requested
|
||||
assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
|
||||
|
||||
// 5. created by application id should be equal to the app id
|
||||
assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
|
||||
|
||||
// 6. password should be matcheable to what we set above
|
||||
err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
|
||||
assert.Nil(suite.T(), err)
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
|
||||
// only registered applications can create accounts, and we don't provide one here.
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have forbidden from our call to the function because we didn't auth
|
||||
suite.EqualValues(http.StatusForbidden, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// set a weak password
|
||||
ctx.Request.Form.Set("password", "weak")
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
// set an invalid locale
|
||||
ctx.Request.Form.Set("locale", "neverneverland")
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// close registrations
|
||||
suite.config.AccountsConfig.OpenRegistration = false
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// remove reason
|
||||
ctx.Request.Form.Set("reason", "")
|
||||
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
|
||||
}
|
||||
|
||||
// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
|
||||
func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = suite.newUserFormHappyPath
|
||||
|
||||
// remove reason
|
||||
ctx.Request.Form.Set("reason", "just cuz")
|
||||
|
||||
suite.accountModule.AccountCreatePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
|
||||
}
|
||||
|
||||
/*
|
||||
TESTING: AccountUpdateCredentialsPATCHHandler
|
||||
*/
|
||||
|
||||
func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
|
||||
|
||||
// put test local account in db
|
||||
err := suite.db.Put(suite.testAccountLocal)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// attach avatar to request
|
||||
aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
|
||||
assert.NoError(suite.T(), err)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
_, err = io.Copy(part, aviFile)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
err = aviFile.Close()
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
err = writer.Close()
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have OK because our request was valid
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
// TODO: implement proper checks here
|
||||
//
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
}
|
||||
|
||||
func TestAccountCreateTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountCreateTestSuite))
|
||||
}
|
@ -1,303 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/oauth2/v4"
|
||||
"github.com/superseriousbusiness/oauth2/v4/models"
|
||||
oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
|
||||
)
|
||||
|
||||
type AccountUpdateTestSuite struct {
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
log *logrus.Logger
|
||||
testAccountLocal *gtsmodel.Account
|
||||
testApplication *gtsmodel.Application
|
||||
testToken oauth2.TokenInfo
|
||||
mockOauthServer *oauth.MockServer
|
||||
mockStorage *storage.MockStorage
|
||||
mediaHandler media.Handler
|
||||
mastoConverter mastotypes.Converter
|
||||
db db.DB
|
||||
accountModule *account.Module
|
||||
newUserFormHappyPath url.Values
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *AccountUpdateTestSuite) SetupSuite() {
|
||||
// some of our subsequent entities need a log so create this here
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
suite.log = log
|
||||
|
||||
suite.testAccountLocal = >smodel.Account{
|
||||
ID: uuid.NewString(),
|
||||
Username: "test_user",
|
||||
}
|
||||
|
||||
// can use this test application throughout
|
||||
suite.testApplication = >smodel.Application{
|
||||
ID: "weeweeeeeeeeeeeeee",
|
||||
Name: "a test application",
|
||||
Website: "https://some-application-website.com",
|
||||
RedirectURI: "http://localhost:8080",
|
||||
ClientID: "a-known-client-id",
|
||||
ClientSecret: "some-secret",
|
||||
Scopes: "read",
|
||||
VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
|
||||
}
|
||||
|
||||
// can use this test token throughout
|
||||
suite.testToken = &oauthmodels.Token{
|
||||
ClientID: "a-known-client-id",
|
||||
RedirectURI: "http://localhost:8080",
|
||||
Scope: "read",
|
||||
Code: "123456789",
|
||||
CodeCreateAt: time.Now(),
|
||||
CodeExpiresIn: time.Duration(10 * time.Minute),
|
||||
}
|
||||
|
||||
// Direct config to local postgres instance
|
||||
c := config.Empty()
|
||||
c.Protocol = "http"
|
||||
c.Host = "localhost"
|
||||
c.DBConfig = &config.DBConfig{
|
||||
Type: "postgres",
|
||||
Address: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "postgres",
|
||||
Database: "postgres",
|
||||
ApplicationName: "gotosocial",
|
||||
}
|
||||
c.MediaConfig = &config.MediaConfig{
|
||||
MaxImageSize: 2 << 20,
|
||||
}
|
||||
c.StorageConfig = &config.StorageConfig{
|
||||
Backend: "local",
|
||||
BasePath: "/tmp",
|
||||
ServeProtocol: "http",
|
||||
ServeHost: "localhost",
|
||||
ServeBasePath: "/fileserver/media",
|
||||
}
|
||||
suite.config = c
|
||||
|
||||
// use an actual database for this, because it's just easier than mocking one out
|
||||
database, err := db.New(context.Background(), c, log)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.db = database
|
||||
|
||||
// we need to mock the oauth server because account creation needs it to create a new token
|
||||
suite.mockOauthServer = &oauth.MockServer{}
|
||||
suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
|
||||
l := suite.log.WithField("func", "GenerateUserAccessToken")
|
||||
token := args.Get(0).(oauth2.TokenInfo)
|
||||
l.Infof("received token %+v", token)
|
||||
clientSecret := args.Get(1).(string)
|
||||
l.Infof("received clientSecret %+v", clientSecret)
|
||||
userID := args.Get(2).(string)
|
||||
l.Infof("received userID %+v", userID)
|
||||
}).Return(&models.Token{
|
||||
Code: "we're authorized now!",
|
||||
}, nil)
|
||||
|
||||
suite.mockStorage = &storage.MockStorage{}
|
||||
// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
|
||||
suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
|
||||
|
||||
// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
|
||||
suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
|
||||
|
||||
suite.mastoConverter = mastotypes.New(suite.config, suite.db)
|
||||
|
||||
// and finally here's the thing we're actually testing!
|
||||
suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
|
||||
}
|
||||
|
||||
func (suite *AccountUpdateTestSuite) TearDownSuite() {
|
||||
if err := suite.db.Stop(context.Background()); err != nil {
|
||||
logrus.Panicf("error closing db connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTest creates a db connection and creates necessary tables before each test
|
||||
func (suite *AccountUpdateTestSuite) SetupTest() {
|
||||
// create all the tables we might need in thie suite
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
for _, m := range models {
|
||||
if err := suite.db.CreateTable(m); err != nil {
|
||||
logrus.Panicf("db connection error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
|
||||
suite.newUserFormHappyPath = url.Values{
|
||||
"reason": []string{"a very good reason that's at least 40 characters i swear"},
|
||||
"username": []string{"test_user"},
|
||||
"email": []string{"user@example.org"},
|
||||
"password": []string{"very-strong-password"},
|
||||
"agreement": []string{"true"},
|
||||
"locale": []string{"en"},
|
||||
}
|
||||
|
||||
// same with accounts config
|
||||
suite.config.AccountsConfig = &config.AccountsConfig{
|
||||
OpenRegistration: true,
|
||||
RequireApproval: true,
|
||||
ReasonRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *AccountUpdateTestSuite) TearDownTest() {
|
||||
|
||||
// remove all the tables we might have used so it's clear for the next test
|
||||
models := []interface{}{
|
||||
>smodel.User{},
|
||||
>smodel.Account{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.Status{},
|
||||
>smodel.Application{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.MediaAttachment{},
|
||||
}
|
||||
for _, m := range models {
|
||||
if err := suite.db.DropTable(m); err != nil {
|
||||
logrus.Panicf("error dropping table: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
/*
|
||||
TESTING: AccountUpdateCredentialsPATCHHandler
|
||||
*/
|
||||
|
||||
func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
|
||||
|
||||
// put test local account in db
|
||||
err := suite.db.Put(suite.testAccountLocal)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// attach avatar to request form
|
||||
avatarFile, err := os.Open("../../media/test/test-jpeg.jpg")
|
||||
assert.NoError(suite.T(), err)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
_, err = io.Copy(avatarPart, avatarFile)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
err = avatarFile.Close()
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// set display name to a new value
|
||||
displayNamePart, err := writer.CreateFormField("display_name")
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
_, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah"))
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// set locked to true
|
||||
lockedPart, err := writer.CreateFormField("locked")
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
_, err = io.Copy(lockedPart, bytes.NewBufferString("true"))
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// close the request writer, the form is now prepared
|
||||
err = writer.Close()
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
|
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have OK because our request was valid
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
// 2. we should have an error message in the result body
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
// TODO: implement proper checks here
|
||||
//
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
|
||||
}
|
||||
|
||||
func TestAccountUpdateTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountUpdateTestSuite))
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AppsPOSTHandler should be served at https://example.org/api/v1/apps
|
||||
// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
|
||||
func (m *Module) AppsPOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "AppsPOSTHandler")
|
||||
l.Trace("entering AppsPOSTHandler")
|
||||
|
||||
form := &mastotypes.ApplicationPOSTRequest{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// permitted length for most fields
|
||||
permittedLength := 64
|
||||
// redirect can be a bit bigger because we probably need to encode data in the redirect uri
|
||||
permittedRedirect := 256
|
||||
|
||||
// check lengths of fields before proceeding so the user can't spam huge entries into the database
|
||||
if len(form.ClientName) > permittedLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)})
|
||||
return
|
||||
}
|
||||
if len(form.Website) > permittedLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)})
|
||||
return
|
||||
}
|
||||
if len(form.RedirectURIs) > permittedRedirect {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)})
|
||||
return
|
||||
}
|
||||
if len(form.Scopes) > permittedLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)})
|
||||
return
|
||||
}
|
||||
|
||||
// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/
|
||||
var scopes string
|
||||
if form.Scopes == "" {
|
||||
scopes = "read"
|
||||
} else {
|
||||
scopes = form.Scopes
|
||||
}
|
||||
|
||||
// generate new IDs for this application and its associated client
|
||||
clientID := uuid.NewString()
|
||||
clientSecret := uuid.NewString()
|
||||
vapidKey := uuid.NewString()
|
||||
|
||||
// generate the application to put in the database
|
||||
app := >smodel.Application{
|
||||
Name: form.ClientName,
|
||||
Website: form.Website,
|
||||
RedirectURI: form.RedirectURIs,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Scopes: scopes,
|
||||
VapidKey: vapidKey,
|
||||
}
|
||||
|
||||
// chuck it in the db
|
||||
if err := m.db.Put(app); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// now we need to model an oauth client from the application that the oauth library can use
|
||||
oc := &oauth.Client{
|
||||
ID: clientID,
|
||||
Secret: clientSecret,
|
||||
Domain: form.RedirectURIs,
|
||||
UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now
|
||||
}
|
||||
|
||||
// chuck it in the db
|
||||
if err := m.db.Put(oc); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
mastoApp, err := m.mastoConverter.AppToMastoSensitive(app)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
|
||||
c.JSON(http.StatusOK, mastoApp)
|
||||
}
|
@ -1,243 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package fileserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
)
|
||||
|
||||
// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
|
||||
//
|
||||
// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
|
||||
// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
|
||||
func (m *FileServer) ServeFile(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "ServeFile",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Trace("received request")
|
||||
|
||||
// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
|
||||
// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
|
||||
// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
|
||||
accountID := c.Param(AccountIDKey)
|
||||
if accountID == "" {
|
||||
l.Debug("missing accountID from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
mediaType := c.Param(MediaTypeKey)
|
||||
if mediaType == "" {
|
||||
l.Debug("missing mediaType from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
mediaSize := c.Param(MediaSizeKey)
|
||||
if mediaSize == "" {
|
||||
l.Debug("missing mediaSize from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
fileName := c.Param(FileNameKey)
|
||||
if fileName == "" {
|
||||
l.Debug("missing fileName from request")
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Only serve media types that are defined in our internal media module
|
||||
switch mediaType {
|
||||
case media.MediaHeader, media.MediaAvatar, media.MediaAttachment:
|
||||
m.serveAttachment(c, accountID, mediaType, mediaSize, fileName)
|
||||
return
|
||||
case media.MediaEmoji:
|
||||
m.serveEmoji(c, accountID, mediaType, mediaSize, fileName)
|
||||
return
|
||||
}
|
||||
l.Debugf("mediatype %s not recognized", mediaType)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
}
|
||||
|
||||
func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "serveAttachment",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
|
||||
// This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
|
||||
switch mediaSize {
|
||||
case media.MediaOriginal, media.MediaSmall, media.MediaStatic:
|
||||
default:
|
||||
l.Debugf("mediasize %s not recognized", mediaSize)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// derive the media id and the file extension from the last part of the request
|
||||
spl := strings.Split(fileName, ".")
|
||||
if len(spl) != 2 {
|
||||
l.Debugf("filename %s not parseable", fileName)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
wantedMediaID := spl[0]
|
||||
fileExtension := spl[1]
|
||||
if wantedMediaID == "" || fileExtension == "" {
|
||||
l.Debugf("filename %s not parseable", fileName)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
|
||||
attachment := >smodel.MediaAttachment{}
|
||||
if err := m.db.GetByID(wantedMediaID, attachment); err != nil {
|
||||
l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// make sure the given account id owns the requested attachment
|
||||
if accountID != attachment.AccountID {
|
||||
l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
|
||||
var storagePath string
|
||||
var contentType string
|
||||
var contentLength int
|
||||
switch mediaSize {
|
||||
case media.MediaOriginal:
|
||||
storagePath = attachment.File.Path
|
||||
contentType = attachment.File.ContentType
|
||||
contentLength = attachment.File.FileSize
|
||||
case media.MediaSmall:
|
||||
storagePath = attachment.Thumbnail.Path
|
||||
contentType = attachment.Thumbnail.ContentType
|
||||
contentLength = attachment.Thumbnail.FileSize
|
||||
}
|
||||
|
||||
// use the path listed on the attachment we pulled out of the database to retrieve the object from storage
|
||||
attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath)
|
||||
if err != nil {
|
||||
l.Debugf("error retrieving from storage: %s", err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes)))
|
||||
|
||||
// finally we can return with all the information we derived above
|
||||
c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{})
|
||||
}
|
||||
|
||||
func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "serveEmoji",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
|
||||
// This corresponds to original-sized emoji as it was uploaded, or static
|
||||
switch mediaSize {
|
||||
case media.MediaOriginal, media.MediaStatic:
|
||||
default:
|
||||
l.Debugf("mediasize %s not recognized", mediaSize)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// derive the media id and the file extension from the last part of the request
|
||||
spl := strings.Split(fileName, ".")
|
||||
if len(spl) != 2 {
|
||||
l.Debugf("filename %s not parseable", fileName)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
wantedEmojiID := spl[0]
|
||||
fileExtension := spl[1]
|
||||
if wantedEmojiID == "" || fileExtension == "" {
|
||||
l.Debugf("filename %s not parseable", fileName)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
|
||||
emoji := >smodel.Emoji{}
|
||||
if err := m.db.GetByID(wantedEmojiID, emoji); err != nil {
|
||||
l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// make sure the instance account id owns the requested emoji
|
||||
instanceAccount := >smodel.Account{}
|
||||
if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil {
|
||||
l.Debugf("error fetching instance account: %s", err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
if accountID != instanceAccount.ID {
|
||||
l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
|
||||
var storagePath string
|
||||
var contentType string
|
||||
var contentLength int
|
||||
switch mediaSize {
|
||||
case media.MediaOriginal:
|
||||
storagePath = emoji.ImagePath
|
||||
contentType = emoji.ImageContentType
|
||||
contentLength = emoji.ImageFileSize
|
||||
case media.MediaStatic:
|
||||
storagePath = emoji.ImageStaticPath
|
||||
contentType = "image/png"
|
||||
contentLength = emoji.ImageStaticFileSize
|
||||
}
|
||||
|
||||
// use the path listed on the emoji we pulled out of the database to retrieve the object from storage
|
||||
emojiBytes, err := m.storage.RetrieveFileFrom(storagePath)
|
||||
if err != nil {
|
||||
l.Debugf("error retrieving emoji from storage: %s", err)
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
return
|
||||
}
|
||||
|
||||
// finally we can return with all the information we derived above
|
||||
c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{})
|
||||
}
|
@ -1,193 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// MediaCreatePOSTHandler handles requests to create/upload media attachments
|
||||
func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// First check this user/account is permitted to create media
|
||||
// There's no point continuing otherwise.
|
||||
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
|
||||
return
|
||||
}
|
||||
|
||||
// extract the media create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||
form := &mastotypes.AttachmentRequest{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||
return
|
||||
}
|
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form)
|
||||
if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
|
||||
l.Debugf("error validating form: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// open the attachment and extract the bytes from it
|
||||
f, err := form.File.Open()
|
||||
if err != nil {
|
||||
l.Debugf("error opening attachment: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)})
|
||||
return
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
size, err := io.Copy(buf, f)
|
||||
if err != nil {
|
||||
l.Debugf("error reading attachment: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)})
|
||||
return
|
||||
}
|
||||
if size == 0 {
|
||||
l.Debug("could not read provided attachment: size 0 bytes")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"})
|
||||
return
|
||||
}
|
||||
|
||||
// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
|
||||
attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Debugf("error reading attachment: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// now we need to add extra fields that the attachment processor doesn't know (from the form)
|
||||
// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
|
||||
|
||||
// first description
|
||||
attachment.Description = form.Description
|
||||
|
||||
// now parse the focus parameter
|
||||
// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
|
||||
var focusx, focusy float32
|
||||
if form.Focus != "" {
|
||||
spl := strings.Split(form.Focus, ",")
|
||||
if len(spl) != 2 {
|
||||
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
xStr := spl[0]
|
||||
yStr := spl[1]
|
||||
if xStr == "" || yStr == "" {
|
||||
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
fx, err := strconv.ParseFloat(xStr, 32)
|
||||
if err != nil {
|
||||
l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
if fx > 1 || fx < -1 {
|
||||
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
focusx = float32(fx)
|
||||
fy, err := strconv.ParseFloat(yStr, 32)
|
||||
if err != nil {
|
||||
l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
if fy > 1 || fy < -1 {
|
||||
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||
return
|
||||
}
|
||||
focusy = float32(fy)
|
||||
}
|
||||
attachment.FileMeta.Focus.X = focusx
|
||||
attachment.FileMeta.Focus.Y = focusy
|
||||
|
||||
// prepare the frontend representation now -- if there are any errors here at least we can bail without
|
||||
// having already put something in the database and then having to clean it up again (eugh)
|
||||
mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment)
|
||||
if err != nil {
|
||||
l.Debugf("error parsing media attachment to frontend type: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// now we can confidently put the attachment in the database
|
||||
if err := m.db.Put(attachment); err != nil {
|
||||
l.Debugf("error storing media attachment in db: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// and return its frontend representation
|
||||
c.JSON(http.StatusAccepted, mastoAttachment)
|
||||
}
|
||||
|
||||
func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error {
|
||||
// check there actually is a file attached and it's not size 0
|
||||
if form.File == nil || form.File.Size == 0 {
|
||||
return errors.New("no attachment given")
|
||||
}
|
||||
|
||||
// a very superficial check to see if no size limits are exceeded
|
||||
// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
|
||||
maxSize := config.MaxVideoSize
|
||||
if config.MaxImageSize > maxSize {
|
||||
maxSize = config.MaxImageSize
|
||||
}
|
||||
if form.File.Size > int64(maxSize) {
|
||||
return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
|
||||
}
|
||||
|
||||
if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
|
||||
return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
|
||||
}
|
||||
|
||||
// TODO: validate focus here
|
||||
|
||||
return nil
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
// Code generated by mockery v2.7.4. DO NOT EDIT.
|
||||
|
||||
package apimodule
|
||||
|
||||
import (
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
db "github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
||||
router "github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type
|
||||
type MockClientAPIModule struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CreateTables provides a mock function with given fields: _a0
|
||||
func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(db.DB) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Route provides a mock function with given fields: s
|
||||
func (_m *MockClientAPIModule) Route(s router.Router) error {
|
||||
ret := _m.Called(s)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(router.Router) error); ok {
|
||||
r0 = rf(s)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
@ -1,462 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
type advancedStatusCreateForm struct {
|
||||
mastotypes.StatusCreateRequest
|
||||
advancedVisibilityFlagsForm
|
||||
}
|
||||
|
||||
type advancedVisibilityFlagsForm struct {
|
||||
// The gotosocial visibility model
|
||||
VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"`
|
||||
// This status will be federated beyond the local timeline(s)
|
||||
Federated *bool `form:"federated"`
|
||||
// This status can be boosted/reblogged
|
||||
Boostable *bool `form:"boostable"`
|
||||
// This status can be replied to
|
||||
Replyable *bool `form:"replyable"`
|
||||
// This status can be liked/faved
|
||||
Likeable *bool `form:"likeable"`
|
||||
}
|
||||
|
||||
// StatusCreatePOSTHandler deals with the creation of new statuses
|
||||
func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||
if err != nil {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// First check this user/account is permitted to post new statuses.
|
||||
// There's no point continuing otherwise.
|
||||
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
|
||||
l.Debugf("couldn't auth: %s", err)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
|
||||
return
|
||||
}
|
||||
|
||||
// extract the status create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||
form := &advancedStatusCreateForm{}
|
||||
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||
l.Debugf("could not parse form from request: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||
return
|
||||
}
|
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form)
|
||||
if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
|
||||
l.Debugf("error validating form: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// At this point we know the account is permitted to post, and we know the request form
|
||||
// is valid (at least according to the API specifications and the instance configuration).
|
||||
// So now we can start digging a bit deeper into the form and building up the new status from it.
|
||||
|
||||
// first we create a new status and add some basic info to it
|
||||
uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
|
||||
thisStatusID := uuid.NewString()
|
||||
thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
|
||||
thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
|
||||
newStatus := >smodel.Status{
|
||||
ID: thisStatusID,
|
||||
URI: thisStatusURI,
|
||||
URL: thisStatusURL,
|
||||
Content: util.HTMLFormat(form.Status),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Local: true,
|
||||
AccountID: authed.Account.ID,
|
||||
ContentWarning: form.SpoilerText,
|
||||
ActivityStreamsType: gtsmodel.ActivityStreamsNote,
|
||||
Sensitive: form.Sensitive,
|
||||
Language: form.Language,
|
||||
CreatedWithApplicationID: authed.Application.ID,
|
||||
Text: form.Status,
|
||||
}
|
||||
|
||||
// check if replyToID is ok
|
||||
if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// check if mediaIDs are ok
|
||||
if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// check if visibility settings are ok
|
||||
if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// handle language settings
|
||||
if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// handle mentions
|
||||
if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it
|
||||
*/
|
||||
|
||||
// put the new status in the database, generating an ID for it in the process
|
||||
if err := m.db.Put(newStatus); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// change the status ID of the media attachments to the new status
|
||||
for _, a := range newStatus.GTSMediaAttachments {
|
||||
a.StatusID = newStatus.ID
|
||||
a.UpdatedAt = time.Now()
|
||||
if err := m.db.UpdateByID(a.ID, a); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{
|
||||
APObjectType: gtsmodel.ActivityStreamsNote,
|
||||
APActivityType: gtsmodel.ActivityStreamsCreate,
|
||||
Activity: newStatus,
|
||||
}
|
||||
|
||||
// return the frontend representation of the new status to the submitter
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
||||
|
||||
func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error {
|
||||
// validate that, structurally, we have a valid status/post
|
||||
if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
|
||||
return errors.New("no status, media, or poll provided")
|
||||
}
|
||||
|
||||
if form.MediaIDs != nil && form.Poll != nil {
|
||||
return errors.New("can't post media + poll in same status")
|
||||
}
|
||||
|
||||
// validate status
|
||||
if form.Status != "" {
|
||||
if len(form.Status) > config.MaxChars {
|
||||
return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// validate media attachments
|
||||
if len(form.MediaIDs) > config.MaxMediaFiles {
|
||||
return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
|
||||
}
|
||||
|
||||
// validate poll
|
||||
if form.Poll != nil {
|
||||
if form.Poll.Options == nil {
|
||||
return errors.New("poll with no options")
|
||||
}
|
||||
if len(form.Poll.Options) > config.PollMaxOptions {
|
||||
return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
|
||||
}
|
||||
for _, p := range form.Poll.Options {
|
||||
if len(p) > config.PollOptionMaxChars {
|
||||
return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validate spoiler text/cw
|
||||
if form.SpoilerText != "" {
|
||||
if len(form.SpoilerText) > config.CWMaxChars {
|
||||
return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// validate post language
|
||||
if form.Language != "" {
|
||||
if err := util.ValidateLanguage(form.Language); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
|
||||
// by default all flags are set to true
|
||||
gtsAdvancedVis := >smodel.VisibilityAdvanced{
|
||||
Federated: true,
|
||||
Boostable: true,
|
||||
Replyable: true,
|
||||
Likeable: true,
|
||||
}
|
||||
|
||||
var gtsBasicVis gtsmodel.Visibility
|
||||
// Advanced takes priority if it's set.
|
||||
// If it's not set, take whatever masto visibility is set.
|
||||
// If *that's* not set either, then just take the account default.
|
||||
// If that's also not set, take the default for the whole instance.
|
||||
if form.VisibilityAdvanced != nil {
|
||||
gtsBasicVis = *form.VisibilityAdvanced
|
||||
} else if form.Visibility != "" {
|
||||
gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility)
|
||||
} else if accountDefaultVis != "" {
|
||||
gtsBasicVis = accountDefaultVis
|
||||
} else {
|
||||
gtsBasicVis = gtsmodel.VisibilityDefault
|
||||
}
|
||||
|
||||
switch gtsBasicVis {
|
||||
case gtsmodel.VisibilityPublic:
|
||||
// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
|
||||
break
|
||||
case gtsmodel.VisibilityUnlocked:
|
||||
// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
|
||||
if form.Federated != nil {
|
||||
gtsAdvancedVis.Federated = *form.Federated
|
||||
}
|
||||
|
||||
if form.Boostable != nil {
|
||||
gtsAdvancedVis.Boostable = *form.Boostable
|
||||
}
|
||||
|
||||
if form.Replyable != nil {
|
||||
gtsAdvancedVis.Replyable = *form.Replyable
|
||||
}
|
||||
|
||||
if form.Likeable != nil {
|
||||
gtsAdvancedVis.Likeable = *form.Likeable
|
||||
}
|
||||
|
||||
case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
|
||||
// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
|
||||
gtsAdvancedVis.Boostable = false
|
||||
|
||||
if form.Federated != nil {
|
||||
gtsAdvancedVis.Federated = *form.Federated
|
||||
}
|
||||
|
||||
if form.Replyable != nil {
|
||||
gtsAdvancedVis.Replyable = *form.Replyable
|
||||
}
|
||||
|
||||
if form.Likeable != nil {
|
||||
gtsAdvancedVis.Likeable = *form.Likeable
|
||||
}
|
||||
|
||||
case gtsmodel.VisibilityDirect:
|
||||
// direct is pretty easy: there's only one possible setting so return it
|
||||
gtsAdvancedVis.Federated = true
|
||||
gtsAdvancedVis.Boostable = false
|
||||
gtsAdvancedVis.Federated = true
|
||||
gtsAdvancedVis.Likeable = true
|
||||
}
|
||||
|
||||
status.Visibility = gtsBasicVis
|
||||
status.VisibilityAdvanced = gtsAdvancedVis
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
|
||||
if form.InReplyToID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
|
||||
//
|
||||
// 1. Does the replied status exist in the database?
|
||||
// 2. Is the replied status marked as replyable?
|
||||
// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
|
||||
//
|
||||
// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
|
||||
repliedStatus := >smodel.Status{}
|
||||
repliedAccount := >smodel.Account{}
|
||||
// check replied status exists + is replyable
|
||||
if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
|
||||
if _, ok := err.(db.ErrNoEntries); ok {
|
||||
return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
|
||||
}
|
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
|
||||
}
|
||||
|
||||
if !repliedStatus.VisibilityAdvanced.Replyable {
|
||||
return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
|
||||
}
|
||||
|
||||
// check replied account is known to us
|
||||
if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
|
||||
if _, ok := err.(db.ErrNoEntries); ok {
|
||||
return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
|
||||
}
|
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
|
||||
}
|
||||
// check if a block exists
|
||||
if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
|
||||
if _, ok := err.(db.ErrNoEntries); !ok {
|
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
|
||||
}
|
||||
} else if blocked {
|
||||
return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
|
||||
}
|
||||
status.InReplyToID = repliedStatus.ID
|
||||
status.InReplyToAccountID = repliedAccount.ID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
|
||||
if form.MediaIDs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
|
||||
attachments := []string{}
|
||||
for _, mediaID := range form.MediaIDs {
|
||||
// check these attachments exist
|
||||
a := >smodel.MediaAttachment{}
|
||||
if err := m.db.GetByID(mediaID, a); err != nil {
|
||||
return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
|
||||
}
|
||||
// check they belong to the requesting account id
|
||||
if a.AccountID != thisAccountID {
|
||||
return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
|
||||
}
|
||||
// check they're not already used in a status
|
||||
if a.StatusID != "" || a.ScheduledStatusID != "" {
|
||||
return fmt.Errorf("media with id %s is already attached to a status", mediaID)
|
||||
}
|
||||
gtsMediaAttachments = append(gtsMediaAttachments, a)
|
||||
attachments = append(attachments, a.ID)
|
||||
}
|
||||
status.GTSMediaAttachments = gtsMediaAttachments
|
||||
status.Attachments = attachments
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
|
||||
if form.Language != "" {
|
||||
status.Language = form.Language
|
||||
} else {
|
||||
status.Language = accountDefaultLanguage
|
||||
}
|
||||
if status.Language == "" {
|
||||
return errors.New("no language given either in status create form or account default")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
|
||||
menchies := []string{}
|
||||
gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating mentions from status: %s", err)
|
||||
}
|
||||
for _, menchie := range gtsMenchies {
|
||||
if err := m.db.Put(menchie); err != nil {
|
||||
return fmt.Errorf("error putting mentions in db: %s", err)
|
||||
}
|
||||
menchies = append(menchies, menchie.TargetAccountID)
|
||||
}
|
||||
// add full populated gts menchies to the status for passing them around conveniently
|
||||
status.GTSMentions = gtsMenchies
|
||||
// add just the ids of the mentioned accounts to the status for putting in the db
|
||||
status.Mentions = menchies
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
|
||||
tags := []string{}
|
||||
gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating hashtags from status: %s", err)
|
||||
}
|
||||
for _, tag := range gtsTags {
|
||||
if err := m.db.Upsert(tag, "name"); err != nil {
|
||||
return fmt.Errorf("error putting tags in db: %s", err)
|
||||
}
|
||||
tags = append(tags, tag.ID)
|
||||
}
|
||||
// add full populated gts tags to the status for passing them around conveniently
|
||||
status.GTSTags = gtsTags
|
||||
// add just the ids of the used tags to the status for putting in the db
|
||||
status.Tags = tags
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
|
||||
emojis := []string{}
|
||||
gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating emojis from status: %s", err)
|
||||
}
|
||||
for _, e := range gtsEmojis {
|
||||
emojis = append(emojis, e.ID)
|
||||
}
|
||||
// add full populated gts emojis to the status for passing them around conveniently
|
||||
status.GTSEmojis = gtsEmojis
|
||||
// add just the ids of the used emojis to the status for putting in the db
|
||||
status.Emojis = emojis
|
||||
return nil
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusDELETEHandler verifies and handles deletion of a status
|
||||
func (m *Module) StatusDELETEHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusDELETEHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't delete status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if targetStatus.AccountID != authed.Account.ID {
|
||||
l.Debug("status doesn't belong to requesting account")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
var boostOfStatus *gtsmodel.Status
|
||||
if targetStatus.BoostOfID != "" {
|
||||
boostOfStatus = >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
|
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
|
||||
l.Errorf("error deleting status from the database: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{
|
||||
APObjectType: gtsmodel.ActivityStreamsNote,
|
||||
APActivityType: gtsmodel.ActivityStreamsDelete,
|
||||
Activity: targetStatus,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusFavePOSTHandler handles fave requests against a given status ID
|
||||
func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusFavePOSTHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't fave status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID)
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
|
||||
l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to see if status is visible")
|
||||
visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil {
|
||||
l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if !visible {
|
||||
l.Trace("status is not visible")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// is the status faveable?
|
||||
if !targetStatus.VisibilityAdvanced.Likeable {
|
||||
l.Debug("status is not faveable")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// it's visible! it's faveable! so let's fave the FUCK out of it
|
||||
fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Debugf("error faveing status: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var boostOfStatus *gtsmodel.Status
|
||||
if targetStatus.BoostOfID != "" {
|
||||
boostOfStatus = >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
|
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// if the targeted status was already faved, faved will be nil
|
||||
// only put the fave in the distributor if something actually changed
|
||||
if fave != nil {
|
||||
fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{
|
||||
APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
|
||||
APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note
|
||||
Activity: fave, // pass the fave along for processing
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
|
||||
func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "statusGETHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
var requestingAccount *gtsmodel.Account
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed but will continue to serve anyway if public status")
|
||||
requestingAccount = nil
|
||||
} else {
|
||||
requestingAccount = authed.Account
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID)
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
|
||||
l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to see if status is visible")
|
||||
visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil {
|
||||
l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if !visible {
|
||||
l.Trace("status is not visible")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
|
||||
favingAccounts, err := m.db.WhoFavedStatus(targetStatus)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// filter the list so the user doesn't see accounts they blocked or which blocked them
|
||||
filteredAccounts := []*gtsmodel.Account{}
|
||||
for _, acc := range favingAccounts {
|
||||
blocked, err := m.db.Blocked(authed.Account.ID, acc.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !blocked {
|
||||
filteredAccounts = append(filteredAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: filter other things here? suspended? muted? silenced?
|
||||
|
||||
// now we can return the masto representation of those accounts
|
||||
mastoAccounts := []*mastotypes.Account{}
|
||||
for _, acc := range filteredAccounts {
|
||||
mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mastoAccounts = append(mastoAccounts, mastoAccount)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoAccounts)
|
||||
}
|
@ -1,112 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusGETHandler is for handling requests to just get one status based on its ID
|
||||
func (m *Module) StatusGETHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "statusGETHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
var requestingAccount *gtsmodel.Account
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed but will continue to serve anyway if public status")
|
||||
requestingAccount = nil
|
||||
} else {
|
||||
requestingAccount = authed.Account
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID)
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
|
||||
l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to see if status is visible")
|
||||
visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil {
|
||||
l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if !visible {
|
||||
l.Trace("status is not visible")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
var boostOfStatus *gtsmodel.Status
|
||||
if targetStatus.BoostOfID != "" {
|
||||
boostOfStatus = >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
|
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
|
||||
func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusUnfavePOSTHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't unfave status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID)
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
|
||||
l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to see if status is visible")
|
||||
visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil {
|
||||
l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if !visible {
|
||||
l.Trace("status is not visible")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// is the status faveable?
|
||||
if !targetStatus.VisibilityAdvanced.Likeable {
|
||||
l.Debug("status is not faveable")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// it's visible! it's faveable! so let's unfave the FUCK out of it
|
||||
fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID)
|
||||
if err != nil {
|
||||
l.Debugf("error unfaveing status: %s", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var boostOfStatus *gtsmodel.Status
|
||||
if targetStatus.BoostOfID != "" {
|
||||
boostOfStatus = >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
|
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// fave might be nil if this status wasn't faved in the first place
|
||||
// we only want to pass the message to the distributor if something actually changed
|
||||
if fave != nil {
|
||||
fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{
|
||||
APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
|
||||
APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave
|
||||
Activity: fave, // pass the undone fave along
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
47
internal/cache/mock_Cache.go
vendored
47
internal/cache/mock_Cache.go
vendored
@ -1,47 +0,0 @@
|
||||
// Code generated by mockery v2.7.4. DO NOT EDIT.
|
||||
|
||||
package cache
|
||||
|
||||
import mock "github.com/stretchr/testify/mock"
|
||||
|
||||
// MockCache is an autogenerated mock type for the Cache type
|
||||
type MockCache struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Fetch provides a mock function with given fields: k
|
||||
func (_m *MockCache) Fetch(k string) (interface{}, error) {
|
||||
ret := _m.Called(k)
|
||||
|
||||
var r0 interface{}
|
||||
if rf, ok := ret.Get(0).(func(string) interface{}); ok {
|
||||
r0 = rf(k)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(interface{})
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(k)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Store provides a mock function with given fields: k, v
|
||||
func (_m *MockCache) Store(k string, v interface{}) error {
|
||||
ret := _m.Called(k, v)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
|
||||
r0 = rf(k, v)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
// Code generated by mockery v2.7.4. DO NOT EDIT.
|
||||
|
||||
package config
|
||||
|
||||
import mock "github.com/stretchr/testify/mock"
|
||||
|
||||
// MockKeyedFlags is an autogenerated mock type for the KeyedFlags type
|
||||
type MockKeyedFlags struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Bool provides a mock function with given fields: k
|
||||
func (_m *MockKeyedFlags) Bool(k string) bool {
|
||||
ret := _m.Called(k)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(k)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Int provides a mock function with given fields: k
|
||||
func (_m *MockKeyedFlags) Int(k string) int {
|
||||
ret := _m.Called(k)
|
||||
|
||||
var r0 int
|
||||
if rf, ok := ret.Get(0).(func(string) int); ok {
|
||||
r0 = rf(k)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsSet provides a mock function with given fields: k
|
||||
func (_m *MockKeyedFlags) IsSet(k string) bool {
|
||||
ret := _m.Called(k)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(k)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// String provides a mock function with given fields: k
|
||||
func (_m *MockKeyedFlags) String(k string) string {
|
||||
ret := _m.Called(k)
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func(string) string); ok {
|
||||
r0 = rf(k)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
@ -20,17 +20,13 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/go-fed/activity/pub"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
const dbTypePostgres string = "POSTGRES"
|
||||
const DBTypePostgres string = "POSTGRES"
|
||||
|
||||
// ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.
|
||||
type ErrNoEntries struct{}
|
||||
@ -126,6 +122,12 @@ type DB interface {
|
||||
// In case of no entries, a 'no entries' error will be returned
|
||||
GetAccountByUserID(userID string, account *gtsmodel.Account) error
|
||||
|
||||
// GetLocalAccountByUsername is a shortcut for the common action of fetching an account ON THIS INSTANCE
|
||||
// according to its username, which should be unique.
|
||||
// The given account pointer will be set to the result of the query, whatever it is.
|
||||
// In case of no entries, a 'no entries' error will be returned
|
||||
GetLocalAccountByUsername(username string, account *gtsmodel.Account) error
|
||||
|
||||
// GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID.
|
||||
// The given slice 'followRequests' will be set to the result of the query, whatever it is.
|
||||
// In case of no entries, a 'no entries' error will be returned
|
||||
@ -277,14 +279,3 @@ type DB interface {
|
||||
// if they exist in the db and conveniently returning them if they do.
|
||||
EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error)
|
||||
}
|
||||
|
||||
// New returns a new database service that satisfies the DB interface and, by extension,
|
||||
// the go-fed database interface described here: https://github.com/go-fed/activity/blob/master/pub/database.go
|
||||
func New(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
|
||||
switch strings.ToUpper(c.DBConfig.Type) {
|
||||
case dbTypePostgres:
|
||||
return newPostgresService(ctx, c, log.WithField("service", "db"))
|
||||
default:
|
||||
return nil, fmt.Errorf("database type %s not supported", c.DBConfig.Type)
|
||||
}
|
||||
}
|
||||
|
@ -21,12 +21,16 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/go-fed/activity/pub"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.
|
||||
@ -35,13 +39,15 @@ type federatingDB struct {
|
||||
locks *sync.Map
|
||||
db DB
|
||||
config *config.Config
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func newFederatingDB(db DB, config *config.Config) pub.Database {
|
||||
func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database {
|
||||
return &federatingDB{
|
||||
locks: new(sync.Map),
|
||||
db: db,
|
||||
config: config,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,8 +104,31 @@ func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {
|
||||
//
|
||||
// The library makes this call only after acquiring a lock first.
|
||||
func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) {
|
||||
|
||||
if !util.IsInboxPath(inbox) {
|
||||
return false, fmt.Errorf("%s is not an inbox URI", inbox.String())
|
||||
}
|
||||
|
||||
if !util.IsStatusesPath(id) {
|
||||
return false, fmt.Errorf("%s is not a status URI", id.String())
|
||||
}
|
||||
_, statusID, err := util.ParseStatusesPath(inbox)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err)
|
||||
}
|
||||
|
||||
if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
// we don't have it
|
||||
return false, nil
|
||||
}
|
||||
// actual error
|
||||
return false, fmt.Errorf("error getting status from db: %s", err)
|
||||
}
|
||||
|
||||
// we must have it
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetInbox returns the first ordered collection page of the outbox at
|
||||
// the specified IRI, for prepending new items.
|
||||
@ -118,26 +147,86 @@ func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOr
|
||||
return nil
|
||||
}
|
||||
|
||||
// Owns returns true if the database has an entry for the IRI and it
|
||||
// exists in the database.
|
||||
//
|
||||
// Owns returns true if the IRI belongs to this instance, and if
|
||||
// the database has an entry for the IRI.
|
||||
// The library makes this call only after acquiring a lock first.
|
||||
func (f *federatingDB) Owns(c context.Context, id *url.URL) (owns bool, err error) {
|
||||
func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) {
|
||||
// if the id host isn't this instance host, we don't own this IRI
|
||||
if id.Host != f.config.Host {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// apparently we own it, so what *is* it?
|
||||
|
||||
// check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
|
||||
if util.IsStatusesPath(id) {
|
||||
_, uid, err := util.ParseStatusesPath(id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
|
||||
}
|
||||
if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
// there are no entries for this status
|
||||
return false, nil
|
||||
}
|
||||
// an actual error happened
|
||||
return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// check if it's a user, eg /users/example_username
|
||||
if util.IsUserPath(id) {
|
||||
username, err := util.ParseUserPath(id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
|
||||
}
|
||||
if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
// there are no entries for this username
|
||||
return false, nil
|
||||
}
|
||||
// an actual error happened
|
||||
return false, fmt.Errorf("database error fetching account with username %s: %s", username, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("could not match activityID: %s", id.String())
|
||||
}
|
||||
|
||||
// ActorForOutbox fetches the actor's IRI for the given outbox IRI.
|
||||
//
|
||||
// The library makes this call only after acquiring a lock first.
|
||||
func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) {
|
||||
return nil, nil
|
||||
if !util.IsOutboxPath(outboxIRI) {
|
||||
return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String())
|
||||
}
|
||||
acct := >smodel.Account{}
|
||||
if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String())
|
||||
}
|
||||
return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String())
|
||||
}
|
||||
return url.Parse(acct.URI)
|
||||
}
|
||||
|
||||
// ActorForInbox fetches the actor's IRI for the given outbox IRI.
|
||||
//
|
||||
// The library makes this call only after acquiring a lock first.
|
||||
func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) {
|
||||
return nil, nil
|
||||
if !util.IsInboxPath(inboxIRI) {
|
||||
return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
|
||||
}
|
||||
acct := >smodel.Account{}
|
||||
if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
|
||||
}
|
||||
return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
|
||||
}
|
||||
return url.Parse(acct.URI)
|
||||
}
|
||||
|
||||
// OutboxForInbox fetches the corresponding actor's outbox IRI for the
|
||||
@ -145,7 +234,17 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto
|
||||
//
|
||||
// The library makes this call only after acquiring a lock first.
|
||||
func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
|
||||
return nil, nil
|
||||
if !util.IsInboxPath(inboxIRI) {
|
||||
return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
|
||||
}
|
||||
acct := >smodel.Account{}
|
||||
if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
|
||||
if _, ok := err.(ErrNoEntries); ok {
|
||||
return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
|
||||
}
|
||||
return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
|
||||
}
|
||||
return url.Parse(acct.OutboxURI)
|
||||
}
|
||||
|
||||
// Exists returns true if the database has an entry for the specified
|
||||
|
@ -1,484 +0,0 @@
|
||||
// Code generated by mockery v2.7.4. DO NOT EDIT.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
|
||||
net "net"
|
||||
|
||||
pub "github.com/go-fed/activity/pub"
|
||||
)
|
||||
|
||||
// MockDB is an autogenerated mock type for the DB type
|
||||
type MockDB struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Blocked provides a mock function with given fields: account1, account2
|
||||
func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) {
|
||||
ret := _m.Called(account1, account2)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string, string) bool); ok {
|
||||
r0 = rf(account1, account2)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
||||
r1 = rf(account1, account2)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateTable provides a mock function with given fields: i
|
||||
func (_m *MockDB) CreateTable(i interface{}) error {
|
||||
ret := _m.Called(i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
|
||||
r0 = rf(i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteByID provides a mock function with given fields: id, i
|
||||
func (_m *MockDB) DeleteByID(id string, i interface{}) error {
|
||||
ret := _m.Called(id, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
|
||||
r0 = rf(id, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteWhere provides a mock function with given fields: key, value, i
|
||||
func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error {
|
||||
ret := _m.Called(key, value, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
|
||||
r0 = rf(key, value, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DropTable provides a mock function with given fields: i
|
||||
func (_m *MockDB) DropTable(i interface{}) error {
|
||||
ret := _m.Called(i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
|
||||
r0 = rf(i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID
|
||||
func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
|
||||
ret := _m.Called(emojis, originAccountID, statusID)
|
||||
|
||||
var r0 []*gtsmodel.Emoji
|
||||
if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok {
|
||||
r0 = rf(emojis, originAccountID, statusID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*gtsmodel.Emoji)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
|
||||
r1 = rf(emojis, originAccountID, statusID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Federation provides a mock function with given fields:
|
||||
func (_m *MockDB) Federation() pub.Database {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 pub.Database
|
||||
if rf, ok := ret.Get(0).(func() pub.Database); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(pub.Database)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetAccountByUserID provides a mock function with given fields: userID, account
|
||||
func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error {
|
||||
ret := _m.Called(userID, account)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok {
|
||||
r0 = rf(userID, account)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetAll provides a mock function with given fields: i
|
||||
func (_m *MockDB) GetAll(i interface{}) error {
|
||||
ret := _m.Called(i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
|
||||
r0 = rf(i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID
|
||||
func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
|
||||
ret := _m.Called(avatar, accountID)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
|
||||
r0 = rf(avatar, accountID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetByID provides a mock function with given fields: id, i
|
||||
func (_m *MockDB) GetByID(id string, i interface{}) error {
|
||||
ret := _m.Called(id, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
|
||||
r0 = rf(id, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests
|
||||
func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
|
||||
ret := _m.Called(accountID, followRequests)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok {
|
||||
r0 = rf(accountID, followRequests)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetFollowersByAccountID provides a mock function with given fields: accountID, followers
|
||||
func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error {
|
||||
ret := _m.Called(accountID, followers)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
|
||||
r0 = rf(accountID, followers)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetFollowingByAccountID provides a mock function with given fields: accountID, following
|
||||
func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error {
|
||||
ret := _m.Called(accountID, following)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
|
||||
r0 = rf(accountID, following)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetHeaderForAccountID provides a mock function with given fields: header, accountID
|
||||
func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error {
|
||||
ret := _m.Called(header, accountID)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
|
||||
r0 = rf(header, accountID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetLastStatusForAccountID provides a mock function with given fields: accountID, status
|
||||
func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error {
|
||||
ret := _m.Called(accountID, status)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok {
|
||||
r0 = rf(accountID, status)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses
|
||||
func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error {
|
||||
ret := _m.Called(accountID, statuses)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok {
|
||||
r0 = rf(accountID, statuses)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit
|
||||
func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error {
|
||||
ret := _m.Called(accountID, statuses, limit)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok {
|
||||
r0 = rf(accountID, statuses, limit)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetWhere provides a mock function with given fields: key, value, i
|
||||
func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error {
|
||||
ret := _m.Called(key, value, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
|
||||
r0 = rf(key, value, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsEmailAvailable provides a mock function with given fields: email
|
||||
func (_m *MockDB) IsEmailAvailable(email string) error {
|
||||
ret := _m.Called(email)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(email)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsHealthy provides a mock function with given fields: ctx
|
||||
func (_m *MockDB) IsHealthy(ctx context.Context) error {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsUsernameAvailable provides a mock function with given fields: username
|
||||
func (_m *MockDB) IsUsernameAvailable(username string) error {
|
||||
ret := _m.Called(username)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(username)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID
|
||||
func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
|
||||
ret := _m.Called(targetAccounts, originAccountID, statusID)
|
||||
|
||||
var r0 []*gtsmodel.Mention
|
||||
if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok {
|
||||
r0 = rf(targetAccounts, originAccountID, statusID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*gtsmodel.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
|
||||
r1 = rf(targetAccounts, originAccountID, statusID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID
|
||||
func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) {
|
||||
ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID)
|
||||
|
||||
var r0 *gtsmodel.User
|
||||
if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok {
|
||||
r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*gtsmodel.User)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok {
|
||||
r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Put provides a mock function with given fields: i
|
||||
func (_m *MockDB) Put(i interface{}) error {
|
||||
ret := _m.Called(i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
|
||||
r0 = rf(i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID
|
||||
func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
|
||||
ret := _m.Called(mediaAttachment, accountID)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
|
||||
r0 = rf(mediaAttachment, accountID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Stop provides a mock function with given fields: ctx
|
||||
func (_m *MockDB) Stop(ctx context.Context) error {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID
|
||||
func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
|
||||
ret := _m.Called(tags, originAccountID, statusID)
|
||||
|
||||
var r0 []*gtsmodel.Tag
|
||||
if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok {
|
||||
r0 = rf(tags, originAccountID, statusID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*gtsmodel.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
|
||||
r1 = rf(tags, originAccountID, statusID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateByID provides a mock function with given fields: id, i
|
||||
func (_m *MockDB) UpdateByID(id string, i interface{}) error {
|
||||
ret := _m.Called(id, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
|
||||
r0 = rf(id, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateOneByID provides a mock function with given fields: id, key, value, i
|
||||
func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error {
|
||||
ret := _m.Called(id, key, value, i)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok {
|
||||
r0 = rf(id, key, value, i)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
@ -37,7 +37,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@ -46,14 +46,14 @@ import (
|
||||
type postgresService struct {
|
||||
config *config.Config
|
||||
conn *pg.DB
|
||||
log *logrus.Entry
|
||||
log *logrus.Logger
|
||||
cancel context.CancelFunc
|
||||
federationDB pub.Database
|
||||
}
|
||||
|
||||
// newPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
|
||||
// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
|
||||
// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection.
|
||||
func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (DB, error) {
|
||||
func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
|
||||
opts, err := derivePGOptions(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create postgres service: %s", err)
|
||||
@ -67,7 +67,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
|
||||
// this will break the logfmt format we normally log in,
|
||||
// since we can't choose where pg outputs to and it defaults to
|
||||
// stdout. So use this option with care!
|
||||
if log.Logger.GetLevel() >= logrus.TraceLevel {
|
||||
if log.GetLevel() >= logrus.TraceLevel {
|
||||
conn.AddQueryHook(pgdebug.DebugHook{
|
||||
// Print all queries.
|
||||
Verbose: true,
|
||||
@ -95,7 +95,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
federatingDB := newFederatingDB(ps, c)
|
||||
federatingDB := NewFederatingDB(ps, c, log)
|
||||
ps.federationDB = federatingDB
|
||||
|
||||
// we can confidently return this useable postgres service now
|
||||
@ -109,8 +109,8 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
|
||||
// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options
|
||||
// with sensible defaults, or an error if it's not satisfied by the provided config.
|
||||
func derivePGOptions(c *config.Config) (*pg.Options, error) {
|
||||
if strings.ToUpper(c.DBConfig.Type) != dbTypePostgres {
|
||||
return nil, fmt.Errorf("expected db type of %s but got %s", dbTypePostgres, c.DBConfig.Type)
|
||||
if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres {
|
||||
return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type)
|
||||
}
|
||||
|
||||
// validate port
|
||||
@ -341,6 +341,16 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.A
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error {
|
||||
if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil {
|
||||
if err == pg.ErrNoRows {
|
||||
return ErrNoEntries{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
|
||||
if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
|
||||
if err == pg.ErrNoRows {
|
||||
@ -456,21 +466,23 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host)
|
||||
newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
|
||||
|
||||
a := >smodel.Account{
|
||||
Username: username,
|
||||
DisplayName: username,
|
||||
Reason: reason,
|
||||
URL: uris.UserURL,
|
||||
URL: newAccountURIs.UserURL,
|
||||
PrivateKey: key,
|
||||
PublicKey: &key.PublicKey,
|
||||
PublicKeyURI: newAccountURIs.PublicKeyURI,
|
||||
ActorType: gtsmodel.ActivityStreamsPerson,
|
||||
URI: uris.UserURI,
|
||||
InboxURL: uris.InboxURI,
|
||||
OutboxURL: uris.OutboxURI,
|
||||
FollowersURL: uris.FollowersURI,
|
||||
FeaturedCollectionURL: uris.CollectionURI,
|
||||
URI: newAccountURIs.UserURI,
|
||||
InboxURI: newAccountURIs.InboxURI,
|
||||
OutboxURI: newAccountURIs.OutboxURI,
|
||||
FollowersURI: newAccountURIs.FollowersURI,
|
||||
FollowingURI: newAccountURIs.FollowingURI,
|
||||
FeaturedCollectionURI: newAccountURIs.CollectionURI,
|
||||
}
|
||||
if _, err = ps.conn.Model(a).Insert(); err != nil {
|
||||
return nil, err
|
||||
@ -566,6 +578,7 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachmen
|
||||
}
|
||||
|
||||
func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) {
|
||||
// TODO: check domain blocks as well
|
||||
var blocked bool
|
||||
if err := ps.conn.Model(>smodel.Block{}).
|
||||
Where("account_id = ?", account1).Where("target_account_id = ?", account2).
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user