summaryrefslogtreecommitdiff
path: root/internal/api/client/status
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/client/status')
-rw-r--r--internal/api/client/status/status.go118
-rw-r--r--internal/api/client/status/status_test.go58
-rw-r--r--internal/api/client/status/statuscreate.go130
-rw-r--r--internal/api/client/status/statuscreate_test.go297
-rw-r--r--internal/api/client/status/statusdelete.go60
-rw-r--r--internal/api/client/status/statusfave.go60
-rw-r--r--internal/api/client/status/statusfave_test.go158
-rw-r--r--internal/api/client/status/statusfavedby.go60
-rw-r--r--internal/api/client/status/statusfavedby_test.go114
-rw-r--r--internal/api/client/status/statusget.go60
-rw-r--r--internal/api/client/status/statusget_test.go117
-rw-r--r--internal/api/client/status/statusunfave.go60
-rw-r--r--internal/api/client/status/statusunfave_test.go170
13 files changed, 1462 insertions, 0 deletions
diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go
new file mode 100644
index 000000000..ba9295623
--- /dev/null
+++ b/internal/api/client/status/status.go
@@ -0,0 +1,118 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // IDKey is for status UUIDs
+ IDKey = "id"
+ // BasePath is the base path for serving the status API
+ BasePath = "/api/v1/statuses"
+ // BasePathWithID is just the base path with the ID key in it.
+ // Use this anywhere you need to know the ID of the status being queried.
+ BasePathWithID = BasePath + "/:" + IDKey
+
+ // ContextPath is used for fetching context of posts
+ ContextPath = BasePathWithID + "/context"
+
+ // FavouritedPath is for seeing who's faved a given status
+ FavouritedPath = BasePathWithID + "/favourited_by"
+ // FavouritePath is for posting a fave on a status
+ FavouritePath = BasePathWithID + "/favourite"
+ // UnfavouritePath is for removing a fave from a status
+ UnfavouritePath = BasePathWithID + "/unfavourite"
+
+ // RebloggedPath is for seeing who's boosted a given status
+ RebloggedPath = BasePathWithID + "/reblogged_by"
+ // ReblogPath is for boosting/reblogging a given status
+ ReblogPath = BasePathWithID + "/reblog"
+ // UnreblogPath is for undoing a boost/reblog of a given status
+ UnreblogPath = BasePathWithID + "/unreblog"
+
+ // BookmarkPath is for creating a bookmark on a given status
+ BookmarkPath = BasePathWithID + "/bookmark"
+ // UnbookmarkPath is for removing a bookmark from a given status
+ UnbookmarkPath = BasePathWithID + "/unbookmark"
+
+ // MutePath is for muting a given status so that notifications will no longer be received about it.
+ MutePath = BasePathWithID + "/mute"
+ // UnmutePath is for undoing an existing mute
+ UnmutePath = BasePathWithID + "/unmute"
+
+ // PinPath is for pinning a status to an account profile so that it's the first thing people see
+ PinPath = BasePathWithID + "/pin"
+ // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
+ UnpinPath = BasePathWithID + "/unpin"
+)
+
+// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
+ r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
+
+ r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
+ r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
+
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+// muxHandler is a little workaround to overcome the limitations of Gin
+func (m *Module) muxHandler(c *gin.Context) {
+ m.log.Debug("entering mux handler")
+ ru := c.Request.RequestURI
+
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, ContextPath) {
+ // TODO
+ } else if strings.HasPrefix(ru, FavouritedPath) {
+ m.StatusFavedByGETHandler(c)
+ } else {
+ m.StatusGETHandler(c)
+ }
+ }
+}
diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go
new file mode 100644
index 000000000..0f77820a1
--- /dev/null
+++ b/internal/api/client/status/status_test.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type StatusStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ federator federation.Federator
+ processor message.Processor
+ storage storage.Storage
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.Module
+}
diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go
new file mode 100644
index 000000000..02080b042
--- /dev/null
+++ b/internal/api/client/status/statuscreate.go
@@ -0,0 +1,130 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// StatusCreatePOSTHandler deals with the creation of new statuses
+func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ // First check this user/account is permitted to post new statuses.
+ // There's no point continuing otherwise.
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ // extract the status create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &model.AdvancedStatusCreateForm{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusCreate(authed, form)
+ if err != nil {
+ l.Debugf("error processing status create: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
+
+func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error {
+ // validate that, structurally, we have a valid status/post
+ if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
+ return errors.New("no status, media, or poll provided")
+ }
+
+ if form.MediaIDs != nil && form.Poll != nil {
+ return errors.New("can't post media + poll in same status")
+ }
+
+ // validate status
+ if form.Status != "" {
+ if len(form.Status) > config.MaxChars {
+ return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
+ }
+ }
+
+ // validate media attachments
+ if len(form.MediaIDs) > config.MaxMediaFiles {
+ return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
+ }
+
+ // validate poll
+ if form.Poll != nil {
+ if form.Poll.Options == nil {
+ return errors.New("poll with no options")
+ }
+ if len(form.Poll.Options) > config.PollMaxOptions {
+ return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
+ }
+ for _, p := range form.Poll.Options {
+ if len(p) > config.PollOptionMaxChars {
+ return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
+ }
+ }
+ }
+
+ // validate spoiler text/cw
+ if form.SpoilerText != "" {
+ if len(form.SpoilerText) > config.CWMaxChars {
+ return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
+ }
+ }
+
+ // validate post language
+ if form.Language != "" {
+ if err := util.ValidateLanguage(form.Language); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go
new file mode 100644
index 000000000..fb9b48f8a
--- /dev/null
+++ b/internal/api/client/status/statuscreate_test.go
@@ -0,0 +1,297 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusCreateTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusCreateTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusCreateTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// Post a new status with some custom visibility settings
+func (suite *StatusCreateTestSuite) TestPostNewStatus() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a brand new status! #helloworld"},
+ "spoiler_text": {"hello hello"},
+ "sensitive": {"true"},
+ "visibility_advanced": {"mutuals_only"},
+ "likeable": {"false"},
+ "replyable": {"false"},
+ "federated": {"false"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK from our call to the function
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPrivate, statusReply.Visibility)
+ assert.Len(suite.T(), statusReply.Tags, 1)
+ assert.Equal(suite.T(), model.Tag{
+ Name: "helloworld",
+ URL: "http://localhost:8080/tags/helloworld",
+ }, statusReply.Tags[0])
+
+ gtsTag := &gtsmodel.Tag{}
+ err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
+
+ assert.Len(suite.T(), statusReply.Emojis, 1)
+ mastoEmoji := statusReply.Emojis[0]
+ gtsEmoji := testrig.NewTestEmojis()["rainbow"]
+
+ assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
+ assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
+ assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
+}
+
+// Try to reply to a status that doesn't exist
+func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a reply to a status that doesn't exist"},
+ "spoiler_text": {"don't open cuz it won't work"},
+ "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
+}
+
+// Post a reply to the status of a local user that allows replies.
+func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
+ "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
+ assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
+ assert.Len(suite.T(), statusReply.Mentions, 1)
+}
+
+// Take a media file which is currently not associated with a status, and attach it to a new status.
+func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here's an image attachment"},
+ "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+
+ // there should be one media attachment
+ assert.Len(suite.T(), statusReply.MediaAttachments, 1)
+
+ // get the updated media attachment from the database
+ gtsAttachment := &gtsmodel.MediaAttachment{}
+ err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // convert it to a masto attachment
+ gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // compare it with what we have now
+ assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
+
+ // the status id of the attachment should now be set to the id of the status we just created
+ assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
+}
+
+func TestStatusCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusCreateTestSuite))
+}
diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go
new file mode 100644
index 000000000..e55416522
--- /dev/null
+++ b/internal/api/client/status/statusdelete.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusDELETEHandler verifies and handles deletion of a status
+func (m *Module) StatusDELETEHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusDELETEHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't delete status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status delete: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go
new file mode 100644
index 000000000..888589a8a
--- /dev/null
+++ b/internal/api/client/status/statusfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavePOSTHandler handles fave requests against a given status ID
+func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusFavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't fave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusFave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status fave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go
new file mode 100644
index 000000000..2f779baed
--- /dev/null
+++ b/internal/api/client/status/statusfave_test.go
@@ -0,0 +1,158 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// fave a status
+func (suite *StatusFaveTestSuite) TestPostFave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.True(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+}
+
+// try to fave a status that's not faveable
+func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
+}
+
+func TestStatusFaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFaveTestSuite))
+}
diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go
new file mode 100644
index 000000000..799acb7d2
--- /dev/null
+++ b/internal/api/client/status/statusfavedby.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
+func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoAccounts)
+}
diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go
new file mode 100644
index 000000000..7b72df7bc
--- /dev/null
+++ b/internal/api/client/status/statusfavedby_test.go
@@ -0,0 +1,114 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFavedByTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFavedByTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFavedByTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFavedByTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavedByGETHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ accts := []model.Account{}
+ err = json.Unmarshal(b, &accts)
+ assert.NoError(suite.T(), err)
+
+ assert.Len(suite.T(), accts, 1)
+ assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username)
+}
+
+func TestStatusFavedByTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFavedByTestSuite))
+}
diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go
new file mode 100644
index 000000000..c6239cb36
--- /dev/null
+++ b/internal/api/client/status/statusget.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusGETHandler is for handling requests to just get one status based on its ID
+func (m *Module) StatusGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusGet(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status get: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go
new file mode 100644
index 000000000..b31acebca
--- /dev/null
+++ b/internal/api/client/status/statusget_test.go
@@ -0,0 +1,117 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusGetTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusGetTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusGetTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusGetTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// Post a new status with some custom visibility settings
+func (suite *StatusGetTestSuite) TestPostNewStatus() {
+
+ // t := suite.testTokens["local_account_1"]
+ // oauthToken := oauth.PGTokenToOauthToken(t)
+
+ // // setup
+ // recorder := httptest.NewRecorder()
+ // ctx, _ := gin.CreateTestContext(recorder)
+ // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ // ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting
+ // ctx.Request.Form = url.Values{
+ // "status": {"this is a brand new status! #helloworld"},
+ // "spoiler_text": {"hello hello"},
+ // "sensitive": {"true"},
+ // "visibility_advanced": {"mutuals_only"},
+ // "likeable": {"false"},
+ // "replyable": {"false"},
+ // "federated": {"false"},
+ // }
+ // suite.statusModule.statusGETHandler(ctx)
+
+ // // check response
+
+ // // 1. we should have OK from our call to the function
+ // suite.EqualValues(http.StatusOK, recorder.Code)
+
+ // result := recorder.Result()
+ // defer result.Body.Close()
+ // b, err := ioutil.ReadAll(result.Body)
+ // assert.NoError(suite.T(), err)
+
+ // statusReply := &mastotypes.Status{}
+ // err = json.Unmarshal(b, statusReply)
+ // assert.NoError(suite.T(), err)
+
+ // assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
+ // assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
+ // assert.True(suite.T(), statusReply.Sensitive)
+ // assert.Equal(suite.T(), mastotypes.VisibilityPrivate, statusReply.Visibility)
+ // assert.Len(suite.T(), statusReply.Tags, 1)
+ // assert.Equal(suite.T(), mastotypes.Tag{
+ // Name: "helloworld",
+ // URL: "http://localhost:8080/tags/helloworld",
+ // }, statusReply.Tags[0])
+
+ // gtsTag := &gtsmodel.Tag{}
+ // err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ // assert.NoError(suite.T(), err)
+ // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func TestStatusGetTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusGetTestSuite))
+}
diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go
new file mode 100644
index 000000000..94fd662de
--- /dev/null
+++ b/internal/api/client/status/statusunfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
+func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusUnfavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't unfave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status unfave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go
new file mode 100644
index 000000000..44b1dd3a6
--- /dev/null
+++ b/internal/api/client/status/statusunfave_test.go
@@ -0,0 +1,170 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnfaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusUnfaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusUnfaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusUnfaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// unfave a status
+func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's already faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+// try to unfave a status that's already not faved
+func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's not faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+func TestStatusUnfaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusUnfaveTestSuite))
+}