summaryrefslogtreecommitdiff
path: root/internal/apimodule
diff options
context:
space:
mode:
Diffstat (limited to 'internal/apimodule')
-rw-r--r--internal/apimodule/account/account.go62
-rw-r--r--internal/apimodule/account/accountcreate.go6
-rw-r--r--internal/apimodule/account/accountcreate_test.go59
-rw-r--r--internal/apimodule/account/accountget.go6
-rw-r--r--internal/apimodule/account/accountupdate.go33
-rw-r--r--internal/apimodule/account/accountupdate_test.go48
-rw-r--r--internal/apimodule/account/accountverify.go2
-rw-r--r--internal/apimodule/admin/admin.go84
-rw-r--r--internal/apimodule/admin/emojicreate.go130
-rw-r--r--internal/apimodule/apimodule.go4
-rw-r--r--internal/apimodule/app/app.go25
-rw-r--r--internal/apimodule/app/appcreate.go14
-rw-r--r--internal/apimodule/auth/auth.go8
-rw-r--r--internal/apimodule/auth/auth_test.go49
-rw-r--r--internal/apimodule/auth/authorize.go10
-rw-r--r--internal/apimodule/auth/middleware.go8
-rw-r--r--internal/apimodule/auth/signin.go4
-rw-r--r--internal/apimodule/fileserver/fileserver.go56
-rw-r--r--internal/apimodule/fileserver/servefile.go243
-rw-r--r--internal/apimodule/fileserver/test/servefile_test.go157
-rw-r--r--internal/apimodule/media/media.go73
-rw-r--r--internal/apimodule/media/mediacreate.go192
-rw-r--r--internal/apimodule/media/test/mediacreate_test.go194
-rw-r--r--internal/apimodule/mock_ClientAPIModule.go16
-rw-r--r--internal/apimodule/security/flocblock.go27
-rw-r--r--internal/apimodule/security/security.go50
-rw-r--r--internal/apimodule/status/status.go138
-rw-r--r--internal/apimodule/status/statuscreate.go463
-rw-r--r--internal/apimodule/status/statusdelete.go106
-rw-r--r--internal/apimodule/status/statusfave.go136
-rw-r--r--internal/apimodule/status/statusfavedby.go128
-rw-r--r--internal/apimodule/status/statusget.go111
-rw-r--r--internal/apimodule/status/statusunfave.go136
-rw-r--r--internal/apimodule/status/test/statuscreate_test.go346
-rw-r--r--internal/apimodule/status/test/statusfave_test.go207
-rw-r--r--internal/apimodule/status/test/statusfavedby_test.go159
-rw-r--r--internal/apimodule/status/test/statusget_test.go168
-rw-r--r--internal/apimodule/status/test/statusunfave_test.go219
38 files changed, 3692 insertions, 185 deletions
diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go
index 2d9ddbb72..f4a47f6a2 100644
--- a/internal/apimodule/account/account.go
+++ b/internal/apimodule/account/account.go
@@ -28,7 +28,9 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/router"
@@ -43,21 +45,23 @@ const (
)
type accountModule struct {
- config *config.Config
- db db.DB
- oauthServer oauth.Server
- mediaHandler media.MediaHandler
- log *logrus.Logger
+ config *config.Config
+ db db.DB
+ oauthServer oauth.Server
+ mediaHandler media.MediaHandler
+ mastoConverter mastotypes.Converter
+ log *logrus.Logger
}
// New returns a new account module
-func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
return &accountModule{
- config: config,
- db: db,
- oauthServer: oauthServer,
- mediaHandler: mediaHandler,
- log: log,
+ config: config,
+ db: db,
+ oauthServer: oauthServer,
+ mediaHandler: mediaHandler,
+ mastoConverter: mastoConverter,
+ log: log,
}
}
@@ -65,19 +69,20 @@ func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler
func (m *accountModule) Route(r router.Router) error {
r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler)
r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler)
+ r.AttachHandler(http.MethodPatch, basePathWithID, m.muxHandler)
return nil
}
func (m *accountModule) CreateTables(db db.DB) error {
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
@@ -90,11 +95,16 @@ func (m *accountModule) CreateTables(db db.DB) error {
func (m *accountModule) muxHandler(c *gin.Context) {
ru := c.Request.RequestURI
- if strings.HasPrefix(ru, verifyPath) {
- m.accountVerifyGETHandler(c)
- } else if strings.HasPrefix(ru, updateCredentialsPath) {
- m.accountUpdateCredentialsPATCHHandler(c)
- } else {
- m.accountGETHandler(c)
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, verifyPath) {
+ m.accountVerifyGETHandler(c)
+ } else {
+ m.accountGETHandler(c)
+ }
+ case http.MethodPatch:
+ if strings.HasPrefix(ru, updateCredentialsPath) {
+ m.accountUpdateCredentialsPATCHHandler(c)
+ }
}
}
diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go
index 58b98c0e4..266d820af 100644
--- a/internal/apimodule/account/accountcreate.go
+++ b/internal/apimodule/account/accountcreate.go
@@ -27,10 +27,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
- "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
"github.com/superseriousbusiness/oauth2/v4"
)
@@ -83,7 +83,7 @@ func (m *accountModule) accountCreatePOSTHandler(c *gin.Context) {
// accountCreate does the dirty work of making an account and user in the database.
// It then returns a token to the caller, for use with the new account, as per the
// spec here: https://docs.joinmastodon.org/methods/accounts/
-func (m *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *model.Application) (*mastotypes.Token, error) {
+func (m *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) {
l := m.log.WithField("func", "accountCreate")
// don't store a reason if we don't require one
diff --git a/internal/apimodule/account/accountcreate_test.go b/internal/apimodule/account/accountcreate_test.go
index d14ae3852..8677e3573 100644
--- a/internal/apimodule/account/accountcreate_test.go
+++ b/internal/apimodule/account/accountcreate_test.go
@@ -41,11 +41,13 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
"github.com/superseriousbusiness/oauth2/v4"
"github.com/superseriousbusiness/oauth2/v4/models"
oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
@@ -56,12 +58,13 @@ type AccountCreateTestSuite struct {
suite.Suite
config *config.Config
log *logrus.Logger
- testAccountLocal *model.Account
- testApplication *model.Application
+ testAccountLocal *gtsmodel.Account
+ testApplication *gtsmodel.Application
testToken oauth2.TokenInfo
mockOauthServer *oauth.MockServer
mockStorage *storage.MockStorage
mediaHandler media.MediaHandler
+ mastoConverter mastotypes.Converter
db db.DB
accountModule *accountModule
newUserFormHappyPath url.Values
@@ -78,13 +81,13 @@ func (suite *AccountCreateTestSuite) SetupSuite() {
log.SetLevel(logrus.TraceLevel)
suite.log = log
- suite.testAccountLocal = &model.Account{
+ suite.testAccountLocal = &gtsmodel.Account{
ID: uuid.NewString(),
Username: "test_user",
}
// can use this test application throughout
- suite.testApplication = &model.Application{
+ suite.testApplication = &gtsmodel.Application{
ID: "weeweeeeeeeeeeeeee",
Name: "a test application",
Website: "https://some-application-website.com",
@@ -148,7 +151,7 @@ func (suite *AccountCreateTestSuite) SetupSuite() {
userID := args.Get(2).(string)
l.Infof("received userID %+v", userID)
}).Return(&models.Token{
- Code: "we're authorized now!",
+ Access: "we're authorized now!",
}, nil)
suite.mockStorage = &storage.MockStorage{}
@@ -158,8 +161,10 @@ func (suite *AccountCreateTestSuite) SetupSuite() {
// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
+ suite.mastoConverter = mastotypes.New(suite.config, suite.db)
+
// and finally here's the thing we're actually testing!
- suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule)
+ suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule)
}
func (suite *AccountCreateTestSuite) TearDownSuite() {
@@ -172,14 +177,14 @@ func (suite *AccountCreateTestSuite) TearDownSuite() {
func (suite *AccountCreateTestSuite) SetupTest() {
// create all the tables we might need in thie suite
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := suite.db.CreateTable(m); err != nil {
@@ -210,14 +215,14 @@ func (suite *AccountCreateTestSuite) TearDownTest() {
// remove all the tables we might have used so it's clear for the next test
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := suite.db.DropTable(m); err != nil {
@@ -259,7 +264,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
- t := &mastotypes.Token{}
+ t := &mastomodel.Token{}
err = json.Unmarshal(b, t)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
@@ -267,7 +272,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
// check new account
// 1. we should be able to get the new account from the db
- acct := &model.Account{}
+ acct := &gtsmodel.Account{}
err = suite.db.GetWhere("username", "test_user", acct)
assert.NoError(suite.T(), err)
assert.NotNil(suite.T(), acct)
@@ -288,7 +293,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
// check new user
// 1. we should be able to get the new user from the db
- usr := &model.User{}
+ usr := &gtsmodel.User{}
err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), usr)
diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go
index 5ee93386d..cd4aed22e 100644
--- a/internal/apimodule/account/accountget.go
+++ b/internal/apimodule/account/accountget.go
@@ -23,7 +23,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
)
// accountGetHandler serves the account information held by the server in response to a GET
@@ -37,7 +37,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) {
return
}
- targetAccount := &model.Account{}
+ targetAccount := &gtsmodel.Account{}
if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
if _, ok := err.(db.ErrNoEntries); ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
@@ -47,7 +47,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) {
return
}
- acctInfo, err := m.db.AccountToMastoPublic(targetAccount)
+ acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go
index 6686d3a50..15e9cf0d1 100644
--- a/internal/apimodule/account/accountupdate.go
+++ b/internal/apimodule/account/accountupdate.go
@@ -27,10 +27,11 @@ import (
"net/http"
"github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
- "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
)
// accountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
@@ -67,7 +68,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
}
if form.Discoverable != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &gtsmodel.Account{}); err != nil {
l.Debugf("error updating discoverable: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -75,7 +76,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
}
if form.Bot != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &gtsmodel.Account{}); err != nil {
l.Debugf("error updating bot: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -87,7 +88,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &gtsmodel.Account{}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -98,7 +99,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &gtsmodel.Account{}); err != nil {
l.Debugf("error updating note: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -126,7 +127,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
}
if form.Locked != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -138,14 +139,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &gtsmodel.Account{}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if form.Source.Sensitive != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -156,7 +157,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &model.Account{}); err != nil {
+ if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &gtsmodel.Account{}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -168,14 +169,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
// }
// fetch the account with all updated values set
- updatedAccount := &model.Account{}
+ updatedAccount := &gtsmodel.Account{}
if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- acctSensitive, err := m.db.AccountToMastoSensitive(updatedAccount)
+ acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount)
if err != nil {
l.Tracef("could not convert account into mastosensitive account: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -195,7 +196,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form,
// parsing and checking the image, and doing the necessary updates in the database for this to become
// the account's new avatar image.
-func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) {
+func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
var err error
if int(avatar.Size) > m.config.MediaConfig.MaxImageSize {
err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize)
@@ -217,7 +218,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun
}
// do the setting
- avatarInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "avatar")
+ avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar)
if err != nil {
return nil, fmt.Errorf("error processing avatar: %s", err)
}
@@ -228,7 +229,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun
// UpdateAccountHeader does the dirty work of checking the header part of an account update form,
// parsing and checking the image, and doing the necessary updates in the database for this to become
// the account's new header image.
-func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) {
+func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
var err error
if int(header.Size) > m.config.MediaConfig.MaxImageSize {
err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize)
@@ -250,7 +251,7 @@ func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accoun
}
// do the setting
- headerInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "header")
+ headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader)
if err != nil {
return nil, fmt.Errorf("error processing header: %s", err)
}
diff --git a/internal/apimodule/account/accountupdate_test.go b/internal/apimodule/account/accountupdate_test.go
index 651b4d29d..7ca2190d8 100644
--- a/internal/apimodule/account/accountupdate_test.go
+++ b/internal/apimodule/account/accountupdate_test.go
@@ -39,7 +39,8 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/storage"
@@ -52,12 +53,13 @@ type AccountUpdateTestSuite struct {
suite.Suite
config *config.Config
log *logrus.Logger
- testAccountLocal *model.Account
- testApplication *model.Application
+ testAccountLocal *gtsmodel.Account
+ testApplication *gtsmodel.Application
testToken oauth2.TokenInfo
mockOauthServer *oauth.MockServer
mockStorage *storage.MockStorage
mediaHandler media.MediaHandler
+ mastoConverter mastotypes.Converter
db db.DB
accountModule *accountModule
newUserFormHappyPath url.Values
@@ -74,13 +76,13 @@ func (suite *AccountUpdateTestSuite) SetupSuite() {
log.SetLevel(logrus.TraceLevel)
suite.log = log
- suite.testAccountLocal = &model.Account{
+ suite.testAccountLocal = &gtsmodel.Account{
ID: uuid.NewString(),
Username: "test_user",
}
// can use this test application throughout
- suite.testApplication = &model.Application{
+ suite.testApplication = &gtsmodel.Application{
ID: "weeweeeeeeeeeeeeee",
Name: "a test application",
Website: "https://some-application-website.com",
@@ -154,8 +156,10 @@ func (suite *AccountUpdateTestSuite) SetupSuite() {
// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
+ suite.mastoConverter = mastotypes.New(suite.config, suite.db)
+
// and finally here's the thing we're actually testing!
- suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule)
+ suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule)
}
func (suite *AccountUpdateTestSuite) TearDownSuite() {
@@ -168,14 +172,14 @@ func (suite *AccountUpdateTestSuite) TearDownSuite() {
func (suite *AccountUpdateTestSuite) SetupTest() {
// create all the tables we might need in thie suite
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := suite.db.CreateTable(m); err != nil {
@@ -206,14 +210,14 @@ func (suite *AccountUpdateTestSuite) TearDownTest() {
// remove all the tables we might have used so it's clear for the next test
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := suite.db.DropTable(m); err != nil {
diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go
index fe8d24b22..584ab6122 100644
--- a/internal/apimodule/account/accountverify.go
+++ b/internal/apimodule/account/accountverify.go
@@ -38,7 +38,7 @@ func (m *accountModule) accountVerifyGETHandler(c *gin.Context) {
}
l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID)
- acctSensitive, err := m.db.AccountToMastoSensitive(authed.Account)
+ acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account)
if err != nil {
l.Tracef("could not convert account into mastosensitive account: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go
new file mode 100644
index 000000000..34a0aa2c8
--- /dev/null
+++ b/internal/apimodule/admin/admin.go
@@ -0,0 +1,84 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ basePath = "/api/v1/admin"
+ emojiPath = basePath + "/custom_emojis"
+)
+
+type adminModule struct {
+ config *config.Config
+ db db.DB
+ mediaHandler media.MediaHandler
+ mastoConverter mastotypes.Converter
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
+ return &adminModule{
+ config: config,
+ db: db,
+ mediaHandler: mediaHandler,
+ mastoConverter: mastoConverter,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *adminModule) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, emojiPath, m.emojiCreatePOSTHandler)
+ return nil
+}
+
+func (m *adminModule) CreateTables(db db.DB) error {
+ models := []interface{}{
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
+ &gtsmodel.Emoji{},
+ }
+
+ for _, m := range models {
+ if err := db.CreateTable(m); err != nil {
+ return fmt.Errorf("error creating table: %s", err)
+ }
+ }
+ return nil
+}
diff --git a/internal/apimodule/admin/emojicreate.go b/internal/apimodule/admin/emojicreate.go
new file mode 100644
index 000000000..91457c07c
--- /dev/null
+++ b/internal/apimodule/admin/emojicreate.go
@@ -0,0 +1,130 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (m *adminModule) emojiCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "emojiCreatePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+
+ // make sure we're authed with an admin account
+ authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ if !authed.User.Admin {
+ l.Debugf("user %s not an admin", authed.User.ID)
+ c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"})
+ return
+ }
+
+ // extract the media create form from the request context
+ l.Tracef("parsing request form: %+v", c.Request.Form)
+ form := &mastotypes.EmojiCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ l.Debugf("error parsing form %+v: %s", c.Request.Form, err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateEmoji(form); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // open the emoji and extract the bytes from it
+ f, err := form.Image.Open()
+ if err != nil {
+ l.Debugf("error opening emoji: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)})
+ return
+ }
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ l.Debugf("error reading emoji: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)})
+ return
+ }
+ if size == 0 {
+ l.Debug("could not read provided emoji: size 0 bytes")
+ c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"})
+ return
+ }
+
+ // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
+ emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
+ if err != nil {
+ l.Debugf("error reading emoji: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)})
+ return
+ }
+
+ mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji)
+ if err != nil {
+ l.Debugf("error converting emoji to mastotype: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)})
+ return
+ }
+
+ if err := m.db.Put(emoji); err != nil {
+ l.Debugf("database error while processing emoji: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoEmoji)
+}
+
+func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error {
+ // check there actually is an image attached and it's not size 0
+ if form.Image == nil || form.Image.Size == 0 {
+ return errors.New("no emoji given")
+ }
+
+ // a very superficial check to see if the media size limit is exceeded
+ if form.Image.Size > media.EmojiMaxBytes {
+ return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size)
+ }
+
+ return util.ValidateEmojiShortcode(form.Shortcode)
+}
diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go
index 52275c6df..6d7dbdb83 100644
--- a/internal/apimodule/apimodule.go
+++ b/internal/apimodule/apimodule.go
@@ -25,8 +25,8 @@ import (
)
// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set
-// of functionalities and side effects to a router, by mapping routes and handlers onto it--in other words, a REST API ;)
-// A ClientAPIMpdule corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
+// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
+// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
type ClientAPIModule interface {
Route(s router.Router) error
CreateTables(db db.DB) error
diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go
index 534f4cd3e..08292acd1 100644
--- a/internal/apimodule/app/app.go
+++ b/internal/apimodule/app/app.go
@@ -25,7 +25,8 @@ import (
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
@@ -33,17 +34,19 @@ import (
const appsPath = "/api/v1/apps"
type appModule struct {
- server oauth.Server
- db db.DB
- log *logrus.Logger
+ server oauth.Server
+ db db.DB
+ mastoConverter mastotypes.Converter
+ log *logrus.Logger
}
// New returns a new auth module
-func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule {
+func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
return &appModule{
- server: srv,
- db: db,
- log: log,
+ server: srv,
+ db: db,
+ mastoConverter: mastoConverter,
+ log: log,
}
}
@@ -57,9 +60,9 @@ func (m *appModule) CreateTables(db db.DB) error {
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
- &model.User{},
- &model.Account{},
- &model.Application{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
}
for _, m := range models {
diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go
index cd5aff701..ec52a9d37 100644
--- a/internal/apimodule/app/appcreate.go
+++ b/internal/apimodule/app/appcreate.go
@@ -24,9 +24,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
)
// appsPOSTHandler should be served at https://example.org/api/v1/apps
@@ -78,7 +78,7 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) {
vapidKey := uuid.NewString()
// generate the application to put in the database
- app := &model.Application{
+ app := &gtsmodel.Application{
Name: form.ClientName,
Website: form.Website,
RedirectURI: form.RedirectURIs,
@@ -108,6 +108,12 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) {
return
}
+ mastoApp, err := m.mastoConverter.AppToMastoSensitive(app)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
- c.JSON(http.StatusOK, app.ToMasto())
+ c.JSON(http.StatusOK, mastoApp)
}
diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go
index 3a85a4364..b70adeb43 100644
--- a/internal/apimodule/auth/auth.go
+++ b/internal/apimodule/auth/auth.go
@@ -31,7 +31,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
@@ -75,9 +75,9 @@ func (m *authModule) CreateTables(db db.DB) error {
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
- &model.User{},
- &model.Account{},
- &model.Application{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
}
for _, m := range models {
diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go
index 0ec9b4a41..2c272e985 100644
--- a/internal/apimodule/auth/auth_test.go
+++ b/internal/apimodule/auth/auth_test.go
@@ -22,16 +22,14 @@ import (
"context"
"fmt"
"testing"
- "time"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/router"
"golang.org/x/crypto/bcrypt"
)
@@ -39,9 +37,9 @@ type AuthTestSuite struct {
suite.Suite
oauthServer oauth.Server
db db.DB
- testAccount *model.Account
- testApplication *model.Application
- testUser *model.User
+ testAccount *gtsmodel.Account
+ testApplication *gtsmodel.Application
+ testUser *gtsmodel.User
testClient *oauth.Client
config *config.Config
}
@@ -75,11 +73,11 @@ func (suite *AuthTestSuite) SetupSuite() {
acctID := uuid.NewString()
- suite.testAccount = &model.Account{
+ suite.testAccount = &gtsmodel.Account{
ID: acctID,
Username: "test_user",
}
- suite.testUser = &model.User{
+ suite.testUser = &gtsmodel.User{
EncryptedPassword: string(encryptedPassword),
Email: "user@example.org",
AccountID: acctID,
@@ -89,7 +87,7 @@ func (suite *AuthTestSuite) SetupSuite() {
Secret: "some-secret",
Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
}
- suite.testApplication = &model.Application{
+ suite.testApplication = &gtsmodel.Application{
Name: "a test application",
Website: "https://some-application-website.com",
RedirectURI: "http://localhost:8080",
@@ -115,9 +113,9 @@ func (suite *AuthTestSuite) SetupTest() {
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
- &model.User{},
- &model.Account{},
- &model.Application{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
}
for _, m := range models {
@@ -148,9 +146,9 @@ func (suite *AuthTestSuite) TearDownTest() {
models := []interface{}{
&oauth.Client{},
&oauth.Token{},
- &model.User{},
- &model.Account{},
- &model.Application{},
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Application{},
}
for _, m := range models {
if err := suite.db.DropTable(m); err != nil {
@@ -163,27 +161,6 @@ func (suite *AuthTestSuite) TearDownTest() {
suite.db = nil
}
-func (suite *AuthTestSuite) TestAPIInitialize() {
- log := logrus.New()
- log.SetLevel(logrus.TraceLevel)
-
- r, err := router.New(suite.config, log)
- if err != nil {
- suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err))
- }
-
- api := New(suite.oauthServer, suite.db, log)
- if err := api.Route(r); err != nil {
- suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err))
- }
-
- r.Start()
- time.Sleep(60 * time.Second)
- if err := r.Stop(context.Background()); err != nil {
- suite.FailNow(fmt.Sprintf("error stopping router: %s", err))
- }
-}
-
func TestAuthTestSuite(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go
index 4a27cc20e..bf525e09e 100644
--- a/internal/apimodule/auth/authorize.go
+++ b/internal/apimodule/auth/authorize.go
@@ -27,8 +27,8 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
- "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
)
// authorizeGETHandler should be served as GET at https://example.org/oauth/authorize
@@ -57,7 +57,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"})
return
}
- app := &model.Application{
+ app := &gtsmodel.Application{
ClientID: clientID,
}
if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
@@ -66,7 +66,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) {
}
// we can also use the userid of the user to fetch their username from the db to greet them nicely <3
- user := &model.User{
+ user := &gtsmodel.User{
ID: userID,
}
if err := m.db.GetByID(user.ID, user); err != nil {
@@ -74,7 +74,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) {
return
}
- acct := &model.Account{
+ acct := &gtsmodel.Account{
ID: user.AccountID,
}
diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go
index 32fc24d52..4ca1f47a2 100644
--- a/internal/apimodule/auth/middleware.go
+++ b/internal/apimodule/auth/middleware.go
@@ -20,7 +20,7 @@ package auth
import (
"github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
@@ -46,7 +46,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) {
l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope())
// fetch user's and account for this user id
- user := &model.User{}
+ user := &gtsmodel.User{}
if err := m.db.GetByID(uid, user); err != nil || user == nil {
l.Warnf("no user found for validated uid %s", uid)
return
@@ -54,7 +54,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) {
c.Set(oauth.SessionAuthorizedUser, user)
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
- acct := &model.Account{}
+ acct := &gtsmodel.Account{}
if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
l.Warnf("no account found for validated user %s", uid)
return
@@ -66,7 +66,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) {
// check for application token
if cid := ti.GetClientID(); cid != "" {
l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
- app := &model.Application{}
+ app := &gtsmodel.Application{}
if err := m.db.GetWhere("client_id", cid, app); err != nil {
l.Tracef("no app found for client %s", cid)
}
diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go
index 34146cbfc..a6994c90e 100644
--- a/internal/apimodule/auth/signin.go
+++ b/internal/apimodule/auth/signin.go
@@ -24,7 +24,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"golang.org/x/crypto/bcrypt"
)
@@ -84,7 +84,7 @@ func (m *authModule) validatePassword(email string, password string) (userid str
}
// first we select the user from the database based on email address, bail if no user found for that email
- gtsUser := &model.User{}
+ gtsUser := &gtsmodel.User{}
if err := m.db.GetWhere("email", email, gtsUser); err != nil {
l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go
index bbafff76f..25f3be864 100644
--- a/internal/apimodule/fileserver/fileserver.go
+++ b/internal/apimodule/fileserver/fileserver.go
@@ -1,20 +1,48 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
package fileserver
import (
"fmt"
+ "net/http"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/router"
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
-// fileServer implements the RESTAPIModule interface.
+const (
+ AccountIDKey = "account_id"
+ MediaTypeKey = "media_type"
+ MediaSizeKey = "media_size"
+ FileNameKey = "file_name"
+
+ FilesPath = "files"
+)
+
+// FileServer implements the RESTAPIModule interface.
// The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
-type fileServer struct {
+type FileServer struct {
config *config.Config
db db.DB
storage storage.Storage
@@ -24,34 +52,24 @@ type fileServer struct {
// New returns a new fileServer module
func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
-
- storageBase := config.StorageConfig.BasePath // TODO: do this properly
-
- return &fileServer{
+ return &FileServer{
config: config,
db: db,
storage: storage,
log: log,
- storageBase: storageBase,
+ storageBase: config.StorageConfig.ServeBasePath,
}
}
// Route satisfies the RESTAPIModule interface
-func (m *fileServer) Route(s router.Router) error {
- // s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler)
+func (m *FileServer) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile)
return nil
}
-func (m *fileServer) CreateTables(db db.DB) error {
+func (m *FileServer) CreateTables(db db.DB) error {
models := []interface{}{
- &model.User{},
- &model.Account{},
- &model.Follow{},
- &model.FollowRequest{},
- &model.Status{},
- &model.Application{},
- &model.EmailDomainBlock{},
- &model.MediaAttachment{},
+ &gtsmodel.MediaAttachment{},
}
for _, m := range models {
diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go
new file mode 100644
index 000000000..0421c5095
--- /dev/null
+++ b/internal/apimodule/fileserver/servefile.go
@@ -0,0 +1,243 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package fileserver
+
+import (
+ "bytes"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+)
+
+// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
+//
+// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
+// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
+func (m *FileServer) ServeFile(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "ServeFile",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Trace("received request")
+
+ // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
+ // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
+ // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
+ accountID := c.Param(AccountIDKey)
+ if accountID == "" {
+ l.Debug("missing accountID from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ mediaType := c.Param(MediaTypeKey)
+ if mediaType == "" {
+ l.Debug("missing mediaType from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ mediaSize := c.Param(MediaSizeKey)
+ if mediaSize == "" {
+ l.Debug("missing mediaSize from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ fileName := c.Param(FileNameKey)
+ if fileName == "" {
+ l.Debug("missing fileName from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // Only serve media types that are defined in our internal media module
+ switch mediaType {
+ case media.MediaHeader, media.MediaAvatar, media.MediaAttachment:
+ m.serveAttachment(c, accountID, mediaType, mediaSize, fileName)
+ return
+ case media.MediaEmoji:
+ m.serveEmoji(c, accountID, mediaType, mediaSize, fileName)
+ return
+ }
+ l.Debugf("mediatype %s not recognized", mediaType)
+ c.String(http.StatusNotFound, "404 page not found")
+}
+
+func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "serveAttachment",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+
+ // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
+ switch mediaSize {
+ case media.MediaOriginal, media.MediaSmall, media.MediaStatic:
+ default:
+ l.Debugf("mediasize %s not recognized", mediaSize)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // derive the media id and the file extension from the last part of the request
+ spl := strings.Split(fileName, ".")
+ if len(spl) != 2 {
+ l.Debugf("filename %s not parseable", fileName)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+ wantedMediaID := spl[0]
+ fileExtension := spl[1]
+ if wantedMediaID == "" || fileExtension == "" {
+ l.Debugf("filename %s not parseable", fileName)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
+ attachment := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.Account{}
+ if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil {
+ l.Debugf("error fetching instance account: %s", err)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+ if accountID != instanceAccount.ID {
+ l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
+ var storagePath string
+ var contentType string
+ var contentLength int
+ switch mediaSize {
+ case media.MediaOriginal:
+ storagePath = emoji.ImagePath
+ contentType = emoji.ImageContentType
+ contentLength = emoji.ImageFileSize
+ case media.MediaStatic:
+ storagePath = emoji.ImageStaticPath
+ contentType = "image/png"
+ contentLength = emoji.ImageStaticFileSize
+ }
+
+ // use the path listed on the emoji we pulled out of the database to retrieve the object from storage
+ emojiBytes, err := m.storage.RetrieveFileFrom(storagePath)
+ if err != nil {
+ l.Debugf("error retrieving emoji from storage: %s", err)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // finally we can return with all the information we derived above
+ c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{})
+}
diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go
new file mode 100644
index 000000000..8af2b40b3
--- /dev/null
+++ b/internal/apimodule/fileserver/test/servefile_test.go
@@ -0,0 +1,157 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package test
+
+import (
+ "context"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ServeFileTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ fileServer *fileserver.FileServer
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *ServeFileTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+
+ // setup module being tested
+ suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer)
+}
+
+func (suite *ServeFileTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+func (suite *ServeFileTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+func (suite *ServeFileTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
+ targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"]
+ assert.True(suite.T(), ok)
+ assert.NotNil(suite.T(), targetAttachment)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil)
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the ServeFile function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: fileserver.AccountIDKey,
+ Value: targetAttachment.AccountID,
+ },
+ gin.Param{
+ Key: fileserver.MediaTypeKey,
+ Value: media.MediaAttachment,
+ },
+ gin.Param{
+ Key: fileserver.MediaSizeKey,
+ Value: media.MediaOriginal,
+ },
+ gin.Param{
+ Key: fileserver.FileNameKey,
+ Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID),
+ },
+ }
+
+ // call the function we're testing and check status code
+ suite.fileServer.ServeFile(ctx)
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ b, err := ioutil.ReadAll(recorder.Body)
+ assert.NoError(suite.T(), err)
+ assert.NotNil(suite.T(), b)
+
+ fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path)
+ assert.NoError(suite.T(), err)
+ assert.NotNil(suite.T(), fileInStorage)
+ assert.Equal(suite.T(), b, fileInStorage)
+}
+
+func TestServeFileTestSuite(t *testing.T) {
+ suite.Run(t, new(ServeFileTestSuite))
+}
diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go
new file mode 100644
index 000000000..c8d3d7425
--- /dev/null
+++ b/internal/apimodule/media/media.go
@@ -0,0 +1,73 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const BasePath = "/api/v1/media"
+
+type MediaModule struct {
+ mediaHandler media.MediaHandler
+ config *config.Config
+ db db.DB
+ mastoConverter mastotypes.Converter
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
+ return &MediaModule{
+ mediaHandler: mediaHandler,
+ config: config,
+ db: db,
+ mastoConverter: mastoConverter,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *MediaModule) Route(s router.Router) error {
+ s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler)
+ return nil
+}
+
+func (m *MediaModule) CreateTables(db db.DB) error {
+ models := []interface{}{
+ &gtsmodel.MediaAttachment{},
+ }
+
+ for _, m := range models {
+ if err := db.CreateTable(m); err != nil {
+ return fmt.Errorf("error creating table: %s", err)
+ }
+ }
+ return nil
+}
diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go
new file mode 100644
index 000000000..06b6d5be6
--- /dev/null
+++ b/internal/apimodule/media/mediacreate.go
@@ -0,0 +1,192 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *MediaModule) MediaCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ // First check this user/account is permitted to create media
+ // There's no point continuing otherwise.
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ // extract the media create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &mastotypes.AttachmentRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // open the attachment and extract the bytes from it
+ f, err := form.File.Open()
+ if err != nil {
+ l.Debugf("error opening attachment: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)})
+ return
+ }
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ l.Debugf("error reading attachment: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)})
+ return
+ }
+ if size == 0 {
+ l.Debug("could not read provided attachment: size 0 bytes")
+ c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"})
+ return
+ }
+
+ // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
+ attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
+ if err != nil {
+ l.Debugf("error reading attachment: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)})
+ return
+ }
+
+ // now we need to add extra fields that the attachment processor doesn't know (from the form)
+ // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
+
+ // first description
+ attachment.Description = form.Description
+
+ // now parse the focus parameter
+ // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
+ var focusx, focusy float32
+ if form.Focus != "" {
+ spl := strings.Split(form.Focus, ",")
+ if len(spl) != 2 {
+ l.Debugf("improperly formatted focus %s", form.Focus)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ xStr := spl[0]
+ yStr := spl[1]
+ if xStr == "" || yStr == "" {
+ l.Debugf("improperly formatted focus %s", form.Focus)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ fx, err := strconv.ParseFloat(xStr, 32)
+ if err != nil {
+ l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ if fx > 1 || fx < -1 {
+ l.Debugf("improperly formatted focus %s", form.Focus)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ focusx = float32(fx)
+ fy, err := strconv.ParseFloat(yStr, 32)
+ if err != nil {
+ l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ if fy > 1 || fy < -1 {
+ l.Debugf("improperly formatted focus %s", form.Focus)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
+ return
+ }
+ focusy = float32(fy)
+ }
+ attachment.FileMeta.Focus.X = focusx
+ attachment.FileMeta.Focus.Y = focusy
+
+ // prepare the frontend representation now -- if there are any errors here at least we can bail without
+ // having already put something in the database and then having to clean it up again (eugh)
+ mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment)
+ if err != nil {
+ l.Debugf("error parsing media attachment to frontend type: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)})
+ return
+ }
+
+ // now we can confidently put the attachment in the database
+ if err := m.db.Put(attachment); err != nil {
+ l.Debugf("error storing media attachment in db: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)})
+ return
+ }
+
+ // and return its frontend representation
+ c.JSON(http.StatusAccepted, mastoAttachment)
+}
+
+func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error {
+ // check there actually is a file attached and it's not size 0
+ if form.File == nil || form.File.Size == 0 {
+ return errors.New("no attachment given")
+ }
+
+ // a very superficial check to see if no size limits are exceeded
+ // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
+ maxSize := config.MaxVideoSize
+ if config.MaxImageSize > maxSize {
+ maxSize = config.MaxImageSize
+ }
+ if form.File.Size > int64(maxSize) {
+ return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
+ }
+
+ if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
+ return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
+ }
+
+ // TODO: validate focus here
+
+ return nil
+}
diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go
new file mode 100644
index 000000000..01a0a6a31
--- /dev/null
+++ b/internal/apimodule/media/test/mediacreate_test.go
@@ -0,0 +1,194 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type MediaCreateTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ mediaModule *mediamodule.MediaModule
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *MediaCreateTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+
+ // setup module being tested
+ suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.MediaModule)
+}
+
+func (suite *MediaCreateTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+func (suite *MediaCreateTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+func (suite *MediaCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
+
+ // set up the context for the request
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+
+ // see what's in storage *before* the request
+ storageKeysBeforeRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // create the request
+ buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+ "description": "this is a test image -- a cool background from somewhere",
+ "focus": "-0.5,0.5",
+ })
+ if err != nil {
+ panic(err)
+ }
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
+ ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+
+ // do the actual request
+ suite.mediaModule.MediaCreatePOSTHandler(ctx)
+
+ // check what's in storage *after* the request
+ storageKeysAfterRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // check response
+ suite.EqualValues(http.StatusAccepted, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ fmt.Println(string(b))
+
+ attachmentReply := &mastomodel.Attachment{}
+ err = json.Unmarshal(b, attachmentReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
+ assert.Equal(suite.T(), "image", attachmentReply.Type)
+ assert.EqualValues(suite.T(), mastomodel.MediaMeta{
+ Original: mastomodel.MediaDimensions{
+ Width: 1920,
+ Height: 1080,
+ Size: "1920x1080",
+ Aspect: 1.7777778,
+ },
+ Small: mastomodel.MediaDimensions{
+ Width: 256,
+ Height: 144,
+ Size: "256x144",
+ Aspect: 1.7777778,
+ },
+ Focus: mastomodel.MediaFocus{
+ X: -0.5,
+ Y: 0.5,
+ },
+ }, attachmentReply.Meta)
+ assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
+ assert.NotEmpty(suite.T(), attachmentReply.ID)
+ assert.NotEmpty(suite.T(), attachmentReply.URL)
+ assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
+ assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
+}
+
+func TestMediaCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(MediaCreateTestSuite))
+}
diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go
index 85c7b6ac6..2d4293d0e 100644
--- a/internal/apimodule/mock_ClientAPIModule.go
+++ b/internal/apimodule/mock_ClientAPIModule.go
@@ -4,6 +4,8 @@ package apimodule
import (
mock "github.com/stretchr/testify/mock"
+ db "github.com/superseriousbusiness/gotosocial/internal/db"
+
router "github.com/superseriousbusiness/gotosocial/internal/router"
)
@@ -12,6 +14,20 @@ type MockClientAPIModule struct {
mock.Mock
}
+// CreateTables provides a mock function with given fields: _a0
+func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error {
+ ret := _m.Called(_a0)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(db.DB) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// Route provides a mock function with given fields: s
func (_m *MockClientAPIModule) Route(s router.Router) error {
ret := _m.Called(s)
diff --git a/internal/apimodule/security/flocblock.go b/internal/apimodule/security/flocblock.go
new file mode 100644
index 000000000..4bb011d4d
--- /dev/null
+++ b/internal/apimodule/security/flocblock.go
@@ -0,0 +1,27 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package security
+
+import "github.com/gin-gonic/gin"
+
+// flocBlock prevents google chrome cohort tracking by writing the Permissions-Policy header after all other parts of the request have been completed.
+// See: https://plausible.io/blog/google-floc
+func (m *module) flocBlock(c *gin.Context) {
+ c.Header("Permissions-Policy", "interest-cohort=()")
+}
diff --git a/internal/apimodule/security/security.go b/internal/apimodule/security/security.go
new file mode 100644
index 000000000..cfac2ce1e
--- /dev/null
+++ b/internal/apimodule/security/security.go
@@ -0,0 +1,50 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package security
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// module implements the apiclient interface
+type module struct {
+ config *config.Config
+ log *logrus.Logger
+}
+
+// New returns a new security module
+func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
+ return &module{
+ config: config,
+ log: log,
+ }
+}
+
+func (m *module) Route(s router.Router) error {
+ s.AttachMiddleware(m.flocBlock)
+ return nil
+}
+
+func (m *module) CreateTables(db db.DB) error {
+ return nil
+}
diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go
new file mode 100644
index 000000000..e65293b62
--- /dev/null
+++ b/internal/apimodule/status/status.go
@@ -0,0 +1,138 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ IDKey = "id"
+ BasePath = "/api/v1/statuses"
+ BasePathWithID = BasePath + "/:" + IDKey
+
+ ContextPath = BasePathWithID + "/context"
+
+ FavouritedPath = BasePathWithID + "/favourited_by"
+ FavouritePath = BasePathWithID + "/favourite"
+ UnfavouritePath = BasePathWithID + "/unfavourite"
+
+ RebloggedPath = BasePathWithID + "/reblogged_by"
+ ReblogPath = BasePathWithID + "/reblog"
+ UnreblogPath = BasePathWithID + "/unreblog"
+
+ BookmarkPath = BasePathWithID + "/bookmark"
+ UnbookmarkPath = BasePathWithID + "/unbookmark"
+
+ MutePath = BasePathWithID + "/mute"
+ UnmutePath = BasePathWithID + "/unmute"
+
+ PinPath = BasePathWithID + "/pin"
+ UnpinPath = BasePathWithID + "/unpin"
+)
+
+type StatusModule struct {
+ config *config.Config
+ db db.DB
+ mediaHandler media.MediaHandler
+ mastoConverter mastotypes.Converter
+ distributor distributor.Distributor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule {
+ return &StatusModule{
+ config: config,
+ db: db,
+ mediaHandler: mediaHandler,
+ mastoConverter: mastoConverter,
+ distributor: distributor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *StatusModule) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
+ r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
+
+ r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
+ r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
+
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+func (m *StatusModule) CreateTables(db db.DB) error {
+ models := []interface{}{
+ &gtsmodel.User{},
+ &gtsmodel.Account{},
+ &gtsmodel.Block{},
+ &gtsmodel.Follow{},
+ &gtsmodel.FollowRequest{},
+ &gtsmodel.Status{},
+ &gtsmodel.StatusFave{},
+ &gtsmodel.StatusBookmark{},
+ &gtsmodel.StatusMute{},
+ &gtsmodel.StatusPin{},
+ &gtsmodel.Application{},
+ &gtsmodel.EmailDomainBlock{},
+ &gtsmodel.MediaAttachment{},
+ &gtsmodel.Emoji{},
+ &gtsmodel.Tag{},
+ &gtsmodel.Mention{},
+ }
+
+ for _, m := range models {
+ if err := db.CreateTable(m); err != nil {
+ return fmt.Errorf("error creating table: %s", err)
+ }
+ }
+ return nil
+}
+
+func (m *StatusModule) muxHandler(c *gin.Context) {
+ m.log.Debug("entering mux handler")
+ ru := c.Request.RequestURI
+
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, ContextPath) {
+ // TODO
+ } else if strings.HasPrefix(ru, FavouritedPath) {
+ m.StatusFavedByGETHandler(c)
+ } else {
+ m.StatusGETHandler(c)
+ }
+ }
+}
diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go
new file mode 100644
index 000000000..ce1cc6da7
--- /dev/null
+++ b/internal/apimodule/status/statuscreate.go
@@ -0,0 +1,463 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+type advancedStatusCreateForm struct {
+ mastotypes.StatusCreateRequest
+ advancedVisibilityFlagsForm
+}
+
+type advancedVisibilityFlagsForm struct {
+ // The gotosocial visibility model
+ VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"`
+ // This status will be federated beyond the local timeline(s)
+ Federated *bool `form:"federated"`
+ // This status can be boosted/reblogged
+ Boostable *bool `form:"boostable"`
+ // This status can be replied to
+ Replyable *bool `form:"replyable"`
+ // This status can be liked/faved
+ Likeable *bool `form:"likeable"`
+}
+
+func (m *StatusModule) StatusCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ // First check this user/account is permitted to post new statuses.
+ // There's no point continuing otherwise.
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ // extract the status create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &advancedStatusCreateForm{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // At this point we know the account is permitted to post, and we know the request form
+ // is valid (at least according to the API specifications and the instance configuration).
+ // So now we can start digging a bit deeper into the form and building up the new status from it.
+
+ // first we create a new status and add some basic info to it
+ uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
+ thisStatusID := uuid.NewString()
+ thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
+ thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
+ newStatus := &gtsmodel.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 := &gtsmodel.VisibilityAdvanced{
+ Federated: true,
+ Boostable: true,
+ Replyable: true,
+ Likeable: true,
+ }
+
+ var gtsBasicVis gtsmodel.Visibility
+ // Advanced takes priority if it's set.
+ // If it's not set, take whatever masto visibility is set.
+ // If *that's* not set either, then just take the account default.
+ // If that's also not set, take the default for the whole instance.
+ if form.VisibilityAdvanced != nil {
+ gtsBasicVis = *form.VisibilityAdvanced
+ } else if form.Visibility != "" {
+ gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility)
+ } else if accountDefaultVis != "" {
+ gtsBasicVis = accountDefaultVis
+ } else {
+ gtsBasicVis = gtsmodel.VisibilityDefault
+ }
+
+ switch gtsBasicVis {
+ case gtsmodel.VisibilityPublic:
+ // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
+ break
+ case gtsmodel.VisibilityUnlocked:
+ // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
+ if form.Federated != nil {
+ gtsAdvancedVis.Federated = *form.Federated
+ }
+
+ if form.Boostable != nil {
+ gtsAdvancedVis.Boostable = *form.Boostable
+ }
+
+ if form.Replyable != nil {
+ gtsAdvancedVis.Replyable = *form.Replyable
+ }
+
+ if form.Likeable != nil {
+ gtsAdvancedVis.Likeable = *form.Likeable
+ }
+
+ case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+ // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
+ gtsAdvancedVis.Boostable = false
+
+ if form.Federated != nil {
+ gtsAdvancedVis.Federated = *form.Federated
+ }
+
+ if form.Replyable != nil {
+ gtsAdvancedVis.Replyable = *form.Replyable
+ }
+
+ if form.Likeable != nil {
+ gtsAdvancedVis.Likeable = *form.Likeable
+ }
+
+ case gtsmodel.VisibilityDirect:
+ // direct is pretty easy: there's only one possible setting so return it
+ gtsAdvancedVis.Federated = true
+ gtsAdvancedVis.Boostable = false
+ gtsAdvancedVis.Federated = true
+ gtsAdvancedVis.Likeable = true
+ }
+
+ status.Visibility = gtsBasicVis
+ status.VisibilityAdvanced = gtsAdvancedVis
+ return nil
+}
+
+func (m *StatusModule) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+ if form.InReplyToID == "" {
+ return nil
+ }
+
+ // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
+ //
+ // 1. Does the replied status exist in the database?
+ // 2. Is the replied status marked as replyable?
+ // 3. Does a block exist between either the current account or the account that posted the status it's replying to?
+ //
+ // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
+ repliedStatus := &gtsmodel.Status{}
+ repliedAccount := &gtsmodel.Account{}
+ // check replied status exists + is replyable
+ if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
+ } else {
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+ }
+
+ if !repliedStatus.VisibilityAdvanced.Replyable {
+ return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
+ }
+
+ // check replied account is known to us
+ if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
+ } else {
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+ }
+ // check if a block exists
+ if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+ } else if blocked {
+ return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
+ }
+ status.InReplyToID = repliedStatus.ID
+ status.InReplyToAccountID = repliedAccount.ID
+
+ return nil
+}
+
+func (m *StatusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+ if form.MediaIDs == nil {
+ return nil
+ }
+
+ gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
+ attachments := []string{}
+ for _, mediaID := range form.MediaIDs {
+ // check these attachments exist
+ a := &gtsmodel.MediaAttachment{}
+ if err := m.db.GetByID(mediaID, a); err != nil {
+ return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
+ }
+ // check they belong to the requesting account id
+ if a.AccountID != thisAccountID {
+ return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
+ }
+ // check they're not already used in a status
+ if a.StatusID != "" || a.ScheduledStatusID != "" {
+ return fmt.Errorf("media with id %s is already attached to a status", mediaID)
+ }
+ gtsMediaAttachments = append(gtsMediaAttachments, a)
+ attachments = append(attachments, a.ID)
+ }
+ status.GTSMediaAttachments = gtsMediaAttachments
+ status.Attachments = attachments
+ return nil
+}
+
+func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
+ if form.Language != "" {
+ status.Language = form.Language
+ } else {
+ status.Language = accountDefaultLanguage
+ }
+ if status.Language == "" {
+ return errors.New("no language given either in status create form or account default")
+ }
+ return nil
+}
+
+func (m *StatusModule) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ menchies := []string{}
+ gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating mentions from status: %s", err)
+ }
+ for _, menchie := range gtsMenchies {
+ if err := m.db.Put(menchie); err != nil {
+ return fmt.Errorf("error putting mentions in db: %s", err)
+ }
+ menchies = append(menchies, menchie.TargetAccountID)
+ }
+ // add full populated gts menchies to the status for passing them around conveniently
+ status.GTSMentions = gtsMenchies
+ // add just the ids of the mentioned accounts to the status for putting in the db
+ status.Mentions = menchies
+ return nil
+}
+
+func (m *StatusModule) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ tags := []string{}
+ gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating hashtags from status: %s", err)
+ }
+ for _, tag := range gtsTags {
+ if err := m.db.Upsert(tag, "name"); err != nil {
+ return fmt.Errorf("error putting tags in db: %s", err)
+ }
+ tags = append(tags, tag.ID)
+ }
+ // add full populated gts tags to the status for passing them around conveniently
+ status.GTSTags = gtsTags
+ // add just the ids of the used tags to the status for putting in the db
+ status.Tags = tags
+ return nil
+}
+
+func (m *StatusModule) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ emojis := []string{}
+ gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating emojis from status: %s", err)
+ }
+ for _, e := range gtsEmojis {
+ emojis = append(emojis, e.ID)
+ }
+ // add full populated gts emojis to the status for passing them around conveniently
+ status.GTSEmojis = gtsEmojis
+ // add just the ids of the used emojis to the status for putting in the db
+ status.Emojis = emojis
+ return nil
+}
diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go
new file mode 100644
index 000000000..f67d035d8
--- /dev/null
+++ b/internal/apimodule/status/statusdelete.go
@@ -0,0 +1,106 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *StatusModule) StatusDELETEHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusDELETEHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't delete status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := &gtsmodel.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 = &gtsmodel.Status{}
+ if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+ }
+
+ mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
+ l.Errorf("error deleting status from the database: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ m.distributor.FromClientAPI() <- distributor.FromClientAPI{
+ APObjectType: gtsmodel.ActivityStreamsNote,
+ APActivityType: gtsmodel.ActivityStreamsDelete,
+ Activity: targetStatus,
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go
new file mode 100644
index 000000000..de475b905
--- /dev/null
+++ b/internal/apimodule/status/statusfave.go
@@ -0,0 +1,136 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *StatusModule) StatusFavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusFavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't fave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.Status{}
+ if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+ }
+
+ mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ // if the targeted status was already faved, faved will be nil
+ // only put the fave in the distributor if something actually changed
+ if fave != nil {
+ fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
+ m.distributor.FromClientAPI() <- distributor.FromClientAPI{
+ APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
+ APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note
+ Activity: fave, // pass the fave along for processing
+ }
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go
new file mode 100644
index 000000000..76a50b2ca
--- /dev/null
+++ b/internal/apimodule/status/statusfavedby.go
@@ -0,0 +1,128 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *StatusModule) StatusFavedByGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ var requestingAccount *gtsmodel.Account
+ authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed but will continue to serve anyway if public status")
+ requestingAccount = nil
+ } else {
+ requestingAccount = authed.Account
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := &gtsmodel.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 := &gtsmodel.Account{}
+ if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+ l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ l.Trace("going to see if status is visible")
+ visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+ if err != nil {
+ l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ if !visible {
+ l.Trace("status is not visible")
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
+ favingAccounts, err := m.db.WhoFavedStatus(targetStatus)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // filter the list so the user doesn't see accounts they blocked or which blocked them
+ filteredAccounts := []*gtsmodel.Account{}
+ for _, acc := range favingAccounts {
+ blocked, err := m.db.Blocked(authed.Account.ID, acc.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if !blocked {
+ filteredAccounts = append(filteredAccounts, acc)
+ }
+ }
+
+ // TODO: filter other things here? suspended? muted? silenced?
+
+ // now we can return the masto representation of those accounts
+ mastoAccounts := []*mastotypes.Account{}
+ for _, acc := range filteredAccounts {
+ mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ mastoAccounts = append(mastoAccounts, mastoAccount)
+ }
+
+ c.JSON(http.StatusOK, mastoAccounts)
+}
diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go
new file mode 100644
index 000000000..ed2e89159
--- /dev/null
+++ b/internal/apimodule/status/statusget.go
@@ -0,0 +1,111 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *StatusModule) StatusGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ var requestingAccount *gtsmodel.Account
+ authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed but will continue to serve anyway if public status")
+ requestingAccount = nil
+ } else {
+ requestingAccount = authed.Account
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.Status{}
+ if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+ }
+
+ mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go
new file mode 100644
index 000000000..61ffd8e4c
--- /dev/null
+++ b/internal/apimodule/status/statusunfave.go
@@ -0,0 +1,136 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (m *StatusModule) StatusUnfavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusUnfavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't unfave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.Status{}
+ if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+ }
+
+ mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
+ return
+ }
+
+ // fave might be nil if this status wasn't faved in the first place
+ // we only want to pass the message to the distributor if something actually changed
+ if fave != nil {
+ fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
+ m.distributor.FromClientAPI() <- distributor.FromClientAPI{
+ APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
+ APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave
+ Activity: fave, // pass the undone fave along
+ }
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go
new file mode 100644
index 000000000..6c5aa6b7d
--- /dev/null
+++ b/internal/apimodule/status/test/statuscreate_test.go
@@ -0,0 +1,346 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusCreateTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+ distributor distributor.Distributor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // module being tested
+ statusModule *status.StatusModule
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *StatusCreateTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.distributor = testrig.NewTestDistributor()
+
+ // setup module being tested
+ suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
+}
+
+func (suite *StatusCreateTestSuite) TearDownSuite() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusCreateTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+/*
+ TESTING: StatusCreatePOSTHandler
+*/
+
+// Post a new status with some custom visibility settings
+func (suite *StatusCreateTestSuite) TestPostNewStatus() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a brand new status! #helloworld"},
+ "spoiler_text": {"hello hello"},
+ "sensitive": {"true"},
+ "visibility_advanced": {"mutuals_only"},
+ "likeable": {"false"},
+ "replyable": {"false"},
+ "federated": {"false"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK from our call to the function
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
+ assert.Len(suite.T(), statusReply.Tags, 1)
+ assert.Equal(suite.T(), mastomodel.Tag{
+ Name: "helloworld",
+ URL: "http://localhost:8080/tags/helloworld",
+ }, statusReply.Tags[0])
+
+ gtsTag := &gtsmodel.Tag{}
+ err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
+
+ assert.Len(suite.T(), statusReply.Emojis, 1)
+ mastoEmoji := statusReply.Emojis[0]
+ gtsEmoji := testrig.NewTestEmojis()["rainbow"]
+
+ assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
+ assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
+ assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
+}
+
+// Try to reply to a status that doesn't exist
+func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a reply to a status that doesn't exist"},
+ "spoiler_text": {"don't open cuz it won't work"},
+ "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b))
+}
+
+// Post a reply to the status of a local user that allows replies.
+func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
+ "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+ assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
+ assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
+ assert.Len(suite.T(), statusReply.Mentions, 1)
+}
+
+// Take a media file which is currently not associated with a status, and attach it to a new status.
+func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here's an image attachment"},
+ "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+
+ // there should be one media attachment
+ assert.Len(suite.T(), statusReply.MediaAttachments, 1)
+
+ // get the updated media attachment from the database
+ gtsAttachment := &gtsmodel.MediaAttachment{}
+ err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // convert it to a masto attachment
+ gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // compare it with what we have now
+ assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
+
+ // the status id of the attachment should now be set to the id of the status we just created
+ assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
+}
+
+func TestStatusCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusCreateTestSuite))
+}
diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/apimodule/status/test/statusfave_test.go
new file mode 100644
index 000000000..b15e57e77
--- /dev/null
+++ b/internal/apimodule/status/test/statusfave_test.go
@@ -0,0 +1,207 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFaveTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+ distributor distributor.Distributor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.StatusModule
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *StatusFaveTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.distributor = testrig.NewTestDistributor()
+
+ // setup module being tested
+ suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
+}
+
+func (suite *StatusFaveTestSuite) TearDownSuite() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusFaveTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusFaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+// fave a status
+func (suite *StatusFaveTestSuite) TestPostFave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+ assert.True(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+}
+
+// try to fave a status that's not faveable
+func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b))
+}
+
+func TestStatusFaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFaveTestSuite))
+}
diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/apimodule/status/test/statusfavedby_test.go
new file mode 100644
index 000000000..83f66562b
--- /dev/null
+++ b/internal/apimodule/status/test/statusfavedby_test.go
@@ -0,0 +1,159 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFavedByTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+ distributor distributor.Distributor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.StatusModule
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *StatusFavedByTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.distributor = testrig.NewTestDistributor()
+
+ // setup module being tested
+ suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
+}
+
+func (suite *StatusFavedByTestSuite) TearDownSuite() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusFavedByTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusFavedByTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavedByGETHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ accts := []mastomodel.Account{}
+ err = json.Unmarshal(b, &accts)
+ assert.NoError(suite.T(), err)
+
+ assert.Len(suite.T(), accts, 1)
+ assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username)
+}
+
+func TestStatusFavedByTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFavedByTestSuite))
+}
diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/apimodule/status/test/statusget_test.go
new file mode 100644
index 000000000..2c2e98acd
--- /dev/null
+++ b/internal/apimodule/status/test/statusget_test.go
@@ -0,0 +1,168 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "testing"
+
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusGetTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+ distributor distributor.Distributor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // module being tested
+ statusModule *status.StatusModule
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *StatusGetTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.distributor = testrig.NewTestDistributor()
+
+ // setup module being tested
+ suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
+}
+
+func (suite *StatusGetTestSuite) TearDownSuite() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusGetTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusGetTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+/*
+ TESTING: StatusGetPOSTHandler
+*/
+
+// Post a new status with some custom visibility settings
+func (suite *StatusGetTestSuite) TestPostNewStatus() {
+
+ // t := suite.testTokens["local_account_1"]
+ // oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // // setup
+ // recorder := httptest.NewRecorder()
+ // ctx, _ := gin.CreateTestContext(recorder)
+ // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ // ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting
+ // ctx.Request.Form = url.Values{
+ // "status": {"this is a brand new status! #helloworld"},
+ // "spoiler_text": {"hello hello"},
+ // "sensitive": {"true"},
+ // "visibility_advanced": {"mutuals_only"},
+ // "likeable": {"false"},
+ // "replyable": {"false"},
+ // "federated": {"false"},
+ // }
+ // suite.statusModule.statusGETHandler(ctx)
+
+ // // check response
+
+ // // 1. we should have OK from our call to the function
+ // suite.EqualValues(http.StatusOK, recorder.Code)
+
+ // result := recorder.Result()
+ // defer result.Body.Close()
+ // b, err := ioutil.ReadAll(result.Body)
+ // assert.NoError(suite.T(), err)
+
+ // statusReply := &mastomodel.Status{}
+ // err = json.Unmarshal(b, statusReply)
+ // assert.NoError(suite.T(), err)
+
+ // assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
+ // assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
+ // assert.True(suite.T(), statusReply.Sensitive)
+ // assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
+ // assert.Len(suite.T(), statusReply.Tags, 1)
+ // assert.Equal(suite.T(), mastomodel.Tag{
+ // Name: "helloworld",
+ // URL: "http://localhost:8080/tags/helloworld",
+ // }, statusReply.Tags[0])
+
+ // gtsTag := &gtsmodel.Tag{}
+ // err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ // assert.NoError(suite.T(), err)
+ // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func TestStatusGetTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusGetTestSuite))
+}
diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/apimodule/status/test/statusunfave_test.go
new file mode 100644
index 000000000..81276a1ed
--- /dev/null
+++ b/internal/apimodule/status/test/statusunfave_test.go
@@ -0,0 +1,219 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/distributor"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+ mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnfaveTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ mastoConverter mastotypes.Converter
+ mediaHandler media.MediaHandler
+ oauthServer oauth.Server
+ distributor distributor.Distributor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.StatusModule
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *StatusUnfaveTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.distributor = testrig.NewTestDistributor()
+
+ // setup module being tested
+ suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
+}
+
+func (suite *StatusUnfaveTestSuite) TearDownSuite() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusUnfaveTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *StatusUnfaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+// unfave a status
+func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's already faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+// try to unfave a status that's already not faved
+func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's not faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &mastomodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+func TestStatusUnfaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusUnfaveTestSuite))
+}