summaryrefslogtreecommitdiff
path: root/internal/api/client/statuses
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2023-01-02 13:10:50 +0100
committerLibravatar GitHub <noreply@github.com>2023-01-02 12:10:50 +0000
commit941893a774c83802afdc4cc76e1d30c59b6c5585 (patch)
tree6e7296146dedfeac8e83655157270f41e190724b /internal/api/client/statuses
parent[chore]: Bump github.com/abema/go-mp4 from 0.8.0 to 0.9.0 (#1287) (diff)
downloadgotosocial-941893a774c83802afdc4cc76e1d30c59b6c5585.tar.xz
[chore] The Big Middleware and API Refactor (tm) (#1250)
* interim commit: start refactoring middlewares into package under router * another interim commit, this is becoming a big job * another fucking massive interim commit * refactor bookmarks to new style * ambassador, wiz zeze commits you are spoiling uz * she compiles, we're getting there * we're just normal men; we're just innocent men * apiutil * whoopsie * i'm glad noone reads commit msgs haha :blob_sweat: * use that weirdo go-bytesize library for maxMultipartMemory * fix media module paths
Diffstat (limited to 'internal/api/client/statuses')
-rw-r--r--internal/api/client/statuses/status.go100
-rw-r--r--internal/api/client/statuses/status_test.go98
-rw-r--r--internal/api/client/statuses/statusbookmark.go98
-rw-r--r--internal/api/client/statuses/statusbookmark_test.go83
-rw-r--r--internal/api/client/statuses/statusboost.go101
-rw-r--r--internal/api/client/statuses/statusboost_test.go247
-rw-r--r--internal/api/client/statuses/statusboostedby.go89
-rw-r--r--internal/api/client/statuses/statusboostedby_test.go112
-rw-r--r--internal/api/client/statuses/statuscontext.go100
-rw-r--r--internal/api/client/statuses/statuscreate.go172
-rw-r--r--internal/api/client/statuses/statuscreate_test.go398
-rw-r--r--internal/api/client/statuses/statusdelete.go100
-rw-r--r--internal/api/client/statuses/statusdelete_test.go91
-rw-r--r--internal/api/client/statuses/statusfave.go97
-rw-r--r--internal/api/client/statuses/statusfave_test.go132
-rw-r--r--internal/api/client/statuses/statusfavedby.go98
-rw-r--r--internal/api/client/statuses/statusfavedby_test.go88
-rw-r--r--internal/api/client/statuses/statusget.go97
-rw-r--r--internal/api/client/statuses/statusget_test.go33
-rw-r--r--internal/api/client/statuses/statusunbookmark.go98
-rw-r--r--internal/api/client/statuses/statusunbookmark_test.go78
-rw-r--r--internal/api/client/statuses/statusunboost.go98
-rw-r--r--internal/api/client/statuses/statusunfave.go97
-rw-r--r--internal/api/client/statuses/statusunfave_test.go143
24 files changed, 2848 insertions, 0 deletions
diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go
new file mode 100644
index 000000000..7f58e8c9d
--- /dev/null
+++ b/internal/api/client/statuses/status.go
@@ -0,0 +1,100 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ // IDKey is for status UUIDs
+ IDKey = "id"
+ // BasePath is the base path for serving the statuses API, minus the 'api' prefix
+ BasePath = "/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
+
+ // 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"
+
+ // ContextPath is used for fetching context of posts
+ ContextPath = BasePathWithID + "/context"
+)
+
+type Module struct {
+ processor processing.Processor
+}
+
+func New(processor processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ // create / get / delete status
+ attachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
+ attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
+ attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
+
+ // fave stuff
+ attachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
+ attachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
+ attachHandler(http.MethodGet, FavouritedPath, m.StatusFavedByGETHandler)
+
+ // reblog stuff
+ attachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler)
+ attachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler)
+ attachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler)
+ attachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler)
+ attachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler)
+
+ // context / status thread
+ attachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler)
+}
diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go
new file mode 100644
index 000000000..0bf824fdb
--- /dev/null
+++ b/internal/api/client/statuses/status_test.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/concurrency"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/messages"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ db db.DB
+ tc typeutils.TypeConverter
+ mediaManager media.Manager
+ federator federation.Federator
+ emailSender email.Sender
+ processor processing.Processor
+ storage *storage.Driver
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.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
+ testFollows map[string]*gtsmodel.Follow
+
+ // module being tested
+ statusModule *statuses.Module
+}
+
+func (suite *StatusStandardTestSuite) 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()
+ suite.testFollows = testrig.NewTestFollows()
+}
+
+func (suite *StatusStandardTestSuite) SetupTest() {
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB()
+ suite.tc = testrig.NewTestTypeConverter(suite.db)
+ suite.storage = testrig.NewInMemoryStorage()
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+
+ fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1)
+ clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1)
+
+ suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker)
+ suite.statusModule = statuses.New(suite.processor)
+
+ suite.NoError(suite.processor.Start())
+}
+
+func (suite *StatusStandardTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
diff --git a/internal/api/client/statuses/statusbookmark.go b/internal/api/client/statuses/statusbookmark.go
new file mode 100644
index 000000000..4efa53528
--- /dev/null
+++ b/internal/api/client/statuses/statusbookmark.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusBookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/bookmark statusBookmark
+//
+// Bookmark status with the given ID.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// name: status
+// description: The status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusBookmarkPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusbookmark_test.go b/internal/api/client/statuses/statusbookmark_test.go
new file mode 100644
index 000000000..ba2de78e1
--- /dev/null
+++ b/internal/api/client/statuses/statusbookmark_test.go
@@ -0,0 +1,83 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusBookmarkTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusBookmarkTestSuite) TestPostBookmark() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusBookmarkPOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.True(statusReply.Bookmarked)
+}
+
+func TestStatusBookmarkTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusBookmarkTestSuite))
+}
diff --git a/internal/api/client/statuses/statusboost.go b/internal/api/client/statuses/statusboost.go
new file mode 100644
index 000000000..c8921b1b6
--- /dev/null
+++ b/internal/api/client/statuses/statusboost.go
@@ -0,0 +1,101 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusBoostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/reblog statusReblog
+//
+// Reblog/boost status with the given ID.
+//
+// If the target status is rebloggable/boostable, it will be shared with your followers.
+// This is equivalent to an ActivityPub 'Announce' activity.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// name: status
+// description: The boost of the status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusBoostPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusBoost(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusboost_test.go b/internal/api/client/statuses/statusboost_test.go
new file mode 100644
index 000000000..13ca2acf2
--- /dev/null
+++ b/internal/api/client/statuses/statusboost_test.go
@@ -0,0 +1,247 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ apimodel "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 StatusBoostTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusBoostTestSuite) TestPostBoost() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusBoostPOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.False(statusReply.Sensitive)
+ suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
+
+ suite.Equal(targetStatus.ContentWarning, statusReply.SpoilerText)
+ suite.Equal(targetStatus.Content, statusReply.Content)
+ suite.Equal("the_mighty_zork", statusReply.Account.Username)
+ suite.Len(statusReply.MediaAttachments, 0)
+ suite.Len(statusReply.Mentions, 0)
+ suite.Len(statusReply.Emojis, 0)
+ suite.Len(statusReply.Tags, 0)
+
+ suite.NotNil(statusReply.Application)
+ suite.Equal("really cool gts application", statusReply.Application.Name)
+
+ suite.NotNil(statusReply.Reblog)
+ suite.Equal(1, statusReply.Reblog.ReblogsCount)
+ suite.Equal(1, statusReply.Reblog.FavouritesCount)
+ suite.Equal(targetStatus.Content, statusReply.Reblog.Content)
+ suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
+ suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID)
+ suite.Len(statusReply.Reblog.MediaAttachments, 1)
+ suite.Len(statusReply.Reblog.Tags, 1)
+ suite.Len(statusReply.Reblog.Emojis, 1)
+ suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name)
+}
+
+func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ testStatus := suite.testStatuses["local_account_1_status_5"]
+ testAccount := suite.testAccounts["local_account_1"]
+ testUser := suite.testUsers["local_account_1"]
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, testUser)
+ ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: statuses.IDKey,
+ Value: testStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusBoostPOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ responseStatus := &apimodel.Status{}
+ err = json.Unmarshal(b, responseStatus)
+ suite.NoError(err)
+
+ suite.False(responseStatus.Sensitive)
+ suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility)
+
+ suite.Equal(testStatus.ContentWarning, responseStatus.SpoilerText)
+ suite.Equal(testStatus.Content, responseStatus.Content)
+ suite.Equal("the_mighty_zork", responseStatus.Account.Username)
+ suite.Len(responseStatus.MediaAttachments, 0)
+ suite.Len(responseStatus.Mentions, 0)
+ suite.Len(responseStatus.Emojis, 0)
+ suite.Len(responseStatus.Tags, 0)
+
+ suite.NotNil(responseStatus.Application)
+ suite.Equal("really cool gts application", responseStatus.Application.Name)
+
+ suite.NotNil(responseStatus.Reblog)
+ suite.Equal(1, responseStatus.Reblog.ReblogsCount)
+ suite.Equal(0, responseStatus.Reblog.FavouritesCount)
+ suite.Equal(testStatus.Content, responseStatus.Reblog.Content)
+ suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText)
+ suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID)
+ suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility)
+ suite.Empty(responseStatus.Reblog.MediaAttachments)
+ suite.Empty(responseStatus.Reblog.Tags)
+ suite.Empty(responseStatus.Reblog.Emojis)
+ suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name)
+}
+
+// try to boost a status that's not boostable
+func (suite *StatusBoostTestSuite) TestPostUnboostable() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_4"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusBoostPOSTHandler(ctx)
+
+ // check response
+ suite.Equal(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+ suite.Equal(`{"error":"Forbidden"}`, string(b))
+}
+
+// try to boost a status that's not visible to the user
+func (suite *StatusBoostTestSuite) TestPostNotVisible() {
+ // stop local_account_2 following zork
+ err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, &gtsmodel.Follow{})
+ suite.NoError(err)
+
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusBoostPOSTHandler(ctx)
+
+ // check response
+ suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
+}
+
+func TestStatusBoostTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusBoostTestSuite))
+}
diff --git a/internal/api/client/statuses/statusboostedby.go b/internal/api/client/statuses/statusboostedby.go
new file mode 100644
index 000000000..dc1567dba
--- /dev/null
+++ b/internal/api/client/statuses/statusboostedby.go
@@ -0,0 +1,89 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusBoostedByGETHandler swagger:operation GET /api/v1/statuses/{id}/reblogged_by statusBoostedBy
+//
+// View accounts that have reblogged/boosted the target status.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - read:accounts
+//
+// responses:
+// '200':
+// schema:
+// type: array
+// items:
+// "$ref": "#/definitions/account"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+func (m *Module) StatusBoostedByGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiAccounts, errWithCode := m.processor.StatusBoostedBy(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiAccounts)
+}
diff --git a/internal/api/client/statuses/statusboostedby_test.go b/internal/api/client/statuses/statusboostedby_test.go
new file mode 100644
index 000000000..576dee369
--- /dev/null
+++ b/internal/api/client/statuses/statusboostedby_test.go
@@ -0,0 +1,112 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusBoostedByTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusBoostedByTestSuite) TestRebloggedByOK() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+ targetStatus := suite.testStatuses["local_account_1_status_1"]
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.AddParam("id", targetStatus.ID)
+
+ suite.statusModule.StatusBoostedByGETHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ accounts := []*gtsmodel.Account{}
+ err = json.Unmarshal(b, &accounts)
+ suite.NoError(err)
+
+ if !suite.Len(accounts, 1) {
+ suite.FailNow("should have had 1 account")
+ }
+
+ suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID)
+}
+
+func (suite *StatusBoostedByTestSuite) TestRebloggedByUseBoostWrapperID() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+ targetStatus := suite.testStatuses["admin_account_status_4"] // admin_account_status_4 is a boost of local_account_1_status_1
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.AddParam("id", targetStatus.ID)
+
+ suite.statusModule.StatusBoostedByGETHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ accounts := []*gtsmodel.Account{}
+ err = json.Unmarshal(b, &accounts)
+ suite.NoError(err)
+
+ if !suite.Len(accounts, 1) {
+ suite.FailNow("should have had 1 account")
+ }
+
+ suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID)
+}
+
+func TestStatusBoostedByTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusBoostedByTestSuite))
+}
diff --git a/internal/api/client/statuses/statuscontext.go b/internal/api/client/statuses/statuscontext.go
new file mode 100644
index 000000000..9a6ac9f7f
--- /dev/null
+++ b/internal/api/client/statuses/statuscontext.go
@@ -0,0 +1,100 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context statusContext
+//
+// Return ancestors and descendants of the given status.
+//
+// The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - read:statuses
+//
+// responses:
+// '200':
+// name: statuses
+// description: Status context object.
+// schema:
+// "$ref": "#/definitions/statusContext"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusContextGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ statusContext, errWithCode := m.processor.StatusGetContext(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, statusContext)
+}
diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go
new file mode 100644
index 000000000..d36c93e77
--- /dev/null
+++ b/internal/api/client/statuses/statuscreate.go
@@ -0,0 +1,172 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/validate"
+)
+
+// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
+//
+// Create a new status.
+//
+// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
+// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
+//
+// ---
+// tags:
+// - statuses
+//
+// consumes:
+// - application/json
+// - application/xml
+// - application/x-www-form-urlencoded
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// description: "The newly created status."
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ form := &apimodel.AdvancedStatusCreateForm{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ // DO NOT COMMIT THIS UNCOMMENTED, IT WILL CAUSE MASS CHAOS.
+ // this is being left in as an ode to kim's shitposting.
+ //
+ // user := authed.Account.DisplayName
+ // if user == "" {
+ // user = authed.Account.Username
+ // }
+ // form.Status += "\n\nsent from " + user + "'s iphone\n"
+
+ if err := validateCreateStatus(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusCreate(c.Request.Context(), authed, form)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
+
+func validateCreateStatus(form *apimodel.AdvancedStatusCreateForm) error {
+ hasStatus := form.Status != ""
+ hasMedia := len(form.MediaIDs) != 0
+ hasPoll := form.Poll != nil
+
+ if !hasStatus && !hasMedia && !hasPoll {
+ return errors.New("no status, media, or poll provided")
+ }
+
+ if hasMedia && hasPoll {
+ return errors.New("can't post media + poll in same status")
+ }
+
+ maxChars := config.GetStatusesMaxChars()
+ maxMediaFiles := config.GetStatusesMediaMaxFiles()
+ maxPollOptions := config.GetStatusesPollMaxOptions()
+ maxPollChars := config.GetStatusesPollOptionMaxChars()
+ maxCwChars := config.GetStatusesCWMaxChars()
+
+ if form.Status != "" {
+ if length := len([]rune(form.Status)); length > maxChars {
+ return fmt.Errorf("status too long, %d characters provided but limit is %d", length, maxChars)
+ }
+ }
+
+ if len(form.MediaIDs) > maxMediaFiles {
+ return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), maxMediaFiles)
+ }
+
+ if form.Poll != nil {
+ if form.Poll.Options == nil {
+ return errors.New("poll with no options")
+ }
+ if len(form.Poll.Options) > maxPollOptions {
+ return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), maxPollOptions)
+ }
+ for _, p := range form.Poll.Options {
+ if length := len([]rune(p)); length > maxPollChars {
+ return fmt.Errorf("poll option too long, %d characters provided but limit is %d", length, maxPollChars)
+ }
+ }
+ }
+
+ if form.SpoilerText != "" {
+ if length := len([]rune(form.SpoilerText)); length > maxCwChars {
+ return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", length, maxCwChars)
+ }
+ }
+
+ if form.Language != "" {
+ if err := validate.Language(form.Language); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go
new file mode 100644
index 000000000..3648d7520
--- /dev/null
+++ b/internal/api/client/statuses/statuscreate_test.go
@@ -0,0 +1,398 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusCreateTestSuite struct {
+ StatusStandardTestSuite
+}
+
+const (
+ statusWithLinksAndTags = "#test alright, should be able to post #links with fragments in them now, let's see........\n\nhttps://docs.gotosocial.org/en/latest/user_guide/posts/#links\n\n#gotosocial\n\n(tobi remember to pull the docker image challenge)"
+ statusMarkdown = "# Title\n\n## Smaller title\n\nThis is a post written in [markdown](https://www.markdownguide.org/)\n\n<img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\"/>"
+ statusMarkdownExpected = "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p><img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\" crossorigin=\"anonymous\">"
+)
+
+// Post a new status with some custom visibility settings
+func (suite *StatusCreateTestSuite) TestPostNewStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.Request.Form = url.Values{
+ "status": {"this is a brand new status! #helloworld"},
+ "spoiler_text": {"hello hello"},
+ "sensitive": {"true"},
+ "visibility": {string(apimodel.VisibilityMutualsOnly)},
+ "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)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.Equal("hello hello", statusReply.SpoilerText)
+ suite.Equal("<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", statusReply.Content)
+ suite.True(statusReply.Sensitive)
+ suite.Equal(apimodel.VisibilityPrivate, statusReply.Visibility) // even though we set this status to mutuals only, it should serialize to private, because the mastodon api has no idea about mutuals_only
+ suite.Len(statusReply.Tags, 1)
+ suite.Equal(apimodel.Tag{
+ Name: "helloworld",
+ URL: "http://localhost:8080/tags/helloworld",
+ }, statusReply.Tags[0])
+
+ gtsTag := &gtsmodel.Tag{}
+ err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "name", Value: "helloworld"}}, gtsTag)
+ suite.NoError(err)
+ suite.Equal(statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() {
+ // set default post language of account 1 to markdown
+ testAccount := suite.testAccounts["local_account_1"]
+ testAccount.StatusFormat = "markdown"
+ a := testAccount
+
+ err := suite.db.UpdateAccount(context.Background(), a)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.Equal(a.StatusFormat, "markdown")
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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, a)
+
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", statuses.BasePath), nil)
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.Request.Form = url.Values{
+ "status": {statusMarkdown},
+ "visibility": {string(apimodel.VisibilityPublic)},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.Equal(statusMarkdownExpected, statusReply.Content)
+}
+
+// mention an account that is not yet known to the instance -- it should be looked up and put in the db
+func (suite *StatusCreateTestSuite) TestMentionUnknownAccount() {
+ // first remove remote account 1 from the database so it gets looked up again
+ remoteAccount := suite.testAccounts["remote_account_1"]
+ err := suite.db.DeleteAccount(context.Background(), remoteAccount.ID)
+ suite.NoError(err)
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.Request.Form = url.Values{
+ "status": {"hello @brand_new_person@unknown-instance.com"},
+ "visibility": {string(apimodel.VisibilityPublic)},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ // if the status is properly formatted, that means the account has been put in the db
+ suite.Equal(`<p>hello <span class="h-card"><a href="https://unknown-instance.com/@brand_new_person" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>brand_new_person</span></a></span></p>`, statusReply.Content)
+ suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
+}
+
+func (suite *StatusCreateTestSuite) TestPostAnotherNewStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.Request.Form = url.Values{
+ "status": {statusWithLinksAndTags},
+ }
+ 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)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.Equal("<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let&#39;s see........<br/><br/><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"noopener nofollow noreferrer\" target=\"_blank\">docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br/><br/><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br/><br/>(tobi remember to pull the docker image challenge)</p>", statusReply.Content)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ 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)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.Equal("", statusReply.SpoilerText)
+ suite.Equal("<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: <br/> here&#39;s an emoji that isn&#39;t in the db: :test_emoji:</p>", statusReply.Content)
+
+ suite.Len(statusReply.Emojis, 1)
+ apiEmoji := statusReply.Emojis[0]
+ gtsEmoji := testrig.NewTestEmojis()["rainbow"]
+
+ suite.Equal(gtsEmoji.Shortcode, apiEmoji.Shortcode)
+ suite.Equal(gtsEmoji.ImageURL, apiEmoji.URL)
+ suite.Equal(gtsEmoji.ImageStaticURL, apiEmoji.StaticURL)
+}
+
+// Try to reply to a status that doesn't exist
+func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ 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)
+ suite.NoError(err)
+ suite.Equal(`{"error":"Bad Request: 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.DBTokenToToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ 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)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.Equal("", statusReply.SpoilerText)
+ suite.Equal(fmt.Sprintf("<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@%s\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>%s</span></a></span> this reply should work!</p>", testrig.NewTestAccounts()["local_account_2"].Username, testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
+ suite.False(statusReply.Sensitive)
+ suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
+ suite.Equal(testrig.NewTestStatuses()["local_account_2_status_1"].ID, *statusReply.InReplyToID)
+ suite.Equal(testrig.NewTestAccounts()["local_account_2"].ID, *statusReply.InReplyToAccountID)
+ suite.Len(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.DBTokenToToken(t)
+
+ attachment := suite.testAttachments["local_account_1_unattached_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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", statuses.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+ ctx.Request.Form = url.Values{
+ "status": {"here's an image attachment"},
+ "media_ids[]": {attachment.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)
+ suite.NoError(err)
+
+ statusResponse := &apimodel.Status{}
+ err = json.Unmarshal(b, statusResponse)
+ suite.NoError(err)
+
+ suite.Equal("", statusResponse.SpoilerText)
+ suite.Equal("<p>here&#39;s an image attachment</p>", statusResponse.Content)
+ suite.False(statusResponse.Sensitive)
+ suite.Equal(apimodel.VisibilityPublic, statusResponse.Visibility)
+
+ // there should be one media attachment
+ suite.Len(statusResponse.MediaAttachments, 1)
+
+ // get the updated media attachment from the database
+ gtsAttachment, err := suite.db.GetAttachmentByID(context.Background(), statusResponse.MediaAttachments[0].ID)
+ suite.NoError(err)
+
+ // convert it to a api attachment
+ gtsAttachmentAsapi, err := suite.tc.AttachmentToAPIAttachment(context.Background(), gtsAttachment)
+ suite.NoError(err)
+
+ // compare it with what we have now
+ suite.EqualValues(statusResponse.MediaAttachments[0], gtsAttachmentAsapi)
+
+ // the status id of the attachment should now be set to the id of the status we just created
+ suite.Equal(statusResponse.ID, gtsAttachment.StatusID)
+}
+
+func TestStatusCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusCreateTestSuite))
+}
diff --git a/internal/api/client/statuses/statusdelete.go b/internal/api/client/statuses/statusdelete.go
new file mode 100644
index 000000000..3db7397db
--- /dev/null
+++ b/internal/api/client/statuses/statusdelete.go
@@ -0,0 +1,100 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusDELETEHandler swagger:operation DELETE /api/v1/statuses/{id} statusDelete
+//
+// Delete status with the given ID. The status must belong to you.
+//
+// The deleted status will be returned in the response. The `text` field will contain the original text of the status as it was submitted.
+// This is useful when doing a 'delete and redraft' type operation.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// description: "The status that was just deleted."
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusDELETEHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusDelete(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusdelete_test.go b/internal/api/client/statuses/statusdelete_test.go
new file mode 100644
index 000000000..9a9ceef8f
--- /dev/null
+++ b/internal/api/client/statuses/statusdelete_test.go
@@ -0,0 +1,91 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusDeleteTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusDeleteTestSuite) TestPostDelete() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+ targetStatus := suite.testStatuses["local_account_1_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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.MethodDelete, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.BasePathWithID, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusDELETEHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &apimodel.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+ suite.NotNil(statusReply)
+
+ if !testrig.WaitFor(func() bool {
+ _, err := suite.db.GetStatusByID(ctx, targetStatus.ID)
+ return errors.Is(err, db.ErrNoEntries)
+ }) {
+ suite.FailNow("time out waiting for status to be deleted")
+ }
+
+}
+
+func TestStatusDeleteTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusDeleteTestSuite))
+}
diff --git a/internal/api/client/statuses/statusfave.go b/internal/api/client/statuses/statusfave.go
new file mode 100644
index 000000000..bd9ded147
--- /dev/null
+++ b/internal/api/client/statuses/statusfave.go
@@ -0,0 +1,97 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/favourite statusFave
+//
+// Star/like/favourite the given status, if permitted.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// description: "The newly faved status."
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusFave(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go
new file mode 100644
index 000000000..20805d87c
--- /dev/null
+++ b/internal/api/client/statuses/statusfave_test.go
@@ -0,0 +1,132 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_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/statuses"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+// fave a status
+func (suite *StatusFaveTestSuite) TestPostFave() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.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 := &apimodel.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(), apimodel.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.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusForbidden, 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":"Forbidden"}`, string(b))
+}
+
+func TestStatusFaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFaveTestSuite))
+}
diff --git a/internal/api/client/statuses/statusfavedby.go b/internal/api/client/statuses/statusfavedby.go
new file mode 100644
index 000000000..aa0f1f8d6
--- /dev/null
+++ b/internal/api/client/statuses/statusfavedby.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavedByGETHandler swagger:operation GET /api/v1/statuses/{id}/favourited_by statusFavedBy
+//
+// View accounts that have faved/starred/liked the target status.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - read:accounts
+//
+// responses:
+// '200':
+// schema:
+// type: array
+// items:
+// "$ref": "#/definitions/account"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiAccounts, errWithCode := m.processor.StatusFavedBy(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiAccounts)
+}
diff --git a/internal/api/client/statuses/statusfavedby_test.go b/internal/api/client/statuses/statusfavedby_test.go
new file mode 100644
index 000000000..fc04c490e
--- /dev/null
+++ b/internal/api/client/statuses/statusfavedby_test.go
@@ -0,0 +1,88 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_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/statuses"
+ apimodel "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) TestGetFavedBy() {
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.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 := []apimodel.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/statuses/statusget.go b/internal/api/client/statuses/statusget.go
new file mode 100644
index 000000000..5e7a59027
--- /dev/null
+++ b/internal/api/client/statuses/statusget.go
@@ -0,0 +1,97 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusGETHandler swagger:operation GET /api/v1/statuses/{id} statusGet
+//
+// View status with the given ID.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - read:statuses
+//
+// responses:
+// '200':
+// description: "The requested status."
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusGet(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusget_test.go b/internal/api/client/statuses/statusget_test.go
new file mode 100644
index 000000000..e8e1fd8f4
--- /dev/null
+++ b/internal/api/client/statuses/statusget_test.go
@@ -0,0 +1,33 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type StatusGetTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func TestStatusGetTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusGetTestSuite))
+}
diff --git a/internal/api/client/statuses/statusunbookmark.go b/internal/api/client/statuses/statusunbookmark.go
new file mode 100644
index 000000000..117ef833b
--- /dev/null
+++ b/internal/api/client/statuses/statusunbookmark.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnbookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unbookmark statusUnbookmark
+//
+// Unbookmark status with the given ID.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// name: status
+// description: The status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusUnbookmarkPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusunbookmark_test.go b/internal/api/client/statuses/statusunbookmark_test.go
new file mode 100644
index 000000000..9c4667ad8
--- /dev/null
+++ b/internal/api/client/statuses/statusunbookmark_test.go
@@ -0,0 +1,78 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnbookmarkTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusUnbookmarkTestSuite) TestPostUnbookmark() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.UnbookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: statuses.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnbookmarkPOSTHandler(ctx)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ suite.NoError(err)
+
+ suite.False(statusReply.Bookmarked)
+}
+
+func TestStatusUnbookmarkTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusUnbookmarkTestSuite))
+}
diff --git a/internal/api/client/statuses/statusunboost.go b/internal/api/client/statuses/statusunboost.go
new file mode 100644
index 000000000..e91081195
--- /dev/null
+++ b/internal/api/client/statuses/statusunboost.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnboostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unreblog statusUnreblog
+//
+// Unreblog/unboost status with the given ID.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// name: status
+// description: The unboosted status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusUnboost(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusunfave.go b/internal/api/client/statuses/statusunfave.go
new file mode 100644
index 000000000..57ae88e1e
--- /dev/null
+++ b/internal/api/client/statuses/statusunfave.go
@@ -0,0 +1,97 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnfavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/unfavourite statusUnfave
+//
+// Unstar/unlike/unfavourite the given status.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// description: "The unfaved status."
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ err := errors.New("no status id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.StatusUnfave(c.Request.Context(), authed, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusunfave_test.go b/internal/api/client/statuses/statusunfave_test.go
new file mode 100644
index 000000000..2ca3450a4
--- /dev/null
+++ b/internal/api/client/statuses/statusunfave_test.go
@@ -0,0 +1,143 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 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 statuses_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/statuses"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnfaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+// unfave a status
+func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.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 := &apimodel.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(), apimodel.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.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil)
+ 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(statuses.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // 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: statuses.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 := &apimodel.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(), apimodel.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))
+}