diff options
Diffstat (limited to 'internal/api/client/statuses')
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, >smodel.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 := >smodel.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'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's an emoji that isn'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'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)) +} |