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)) +}  | 
