From 477ae50933ab7447757752ec35bf898db287acff Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Fri, 9 Dec 2022 05:37:12 -0500 Subject: [PATCH] [feature] Allow users to create + delete bookbarks, and view bookmarked statuses (#1168) * Implement Bookmarks * Update based on review comments * Update swagger doc * Fix argument passing to status.Bookmark * Update changed test * Updates based on latest PR review --- ROADMAP.md | 8 +- docs/api/swagger.yaml | 96 ++++++++++++ internal/api/client/bookmarks/bookmarks.go | 50 ++++++ .../api/client/bookmarks/bookmarks_test.go | 148 ++++++++++++++++++ internal/api/client/bookmarks/bookmarksget.go | 107 +++++++++++++ internal/api/client/status/status.go | 3 + internal/api/client/status/statusbookmark.go | 98 ++++++++++++ .../api/client/status/statusbookmark_test.go | 83 ++++++++++ .../api/client/status/statusunbookmark.go | 98 ++++++++++++ .../client/status/statusunbookmark_test.go | 78 +++++++++ internal/db/account.go | 2 + internal/db/bundb/account.go | 32 ++++ internal/db/bundb/account_test.go | 6 + internal/processing/account/account.go | 3 + internal/processing/account/getbookmarks.go | 88 +++++++++++ internal/processing/bookmark.go | 31 ++++ internal/processing/processor.go | 7 + internal/processing/status.go | 8 + internal/processing/status/bookmark.go | 86 ++++++++++ internal/processing/status/bookmark_test.go | 48 ++++++ internal/processing/status/status.go | 4 + internal/processing/status/unbookmark.go | 69 ++++++++ internal/processing/status/unbookmark_test.go | 54 +++++++ internal/typeutils/internaltofrontend_test.go | 2 +- testrig/db.go | 6 + testrig/testmodels.go | 20 +++ 26 files changed, 1230 insertions(+), 5 deletions(-) create mode 100644 internal/api/client/bookmarks/bookmarks.go create mode 100644 internal/api/client/bookmarks/bookmarks_test.go create mode 100644 internal/api/client/bookmarks/bookmarksget.go create mode 100644 internal/api/client/status/statusbookmark.go create mode 100644 internal/api/client/status/statusbookmark_test.go create mode 100644 internal/api/client/status/statusunbookmark.go create mode 100644 internal/api/client/status/statusunbookmark_test.go create mode 100644 internal/processing/account/getbookmarks.go create mode 100644 internal/processing/bookmark.go create mode 100644 internal/processing/status/bookmark.go create mode 100644 internal/processing/status/bookmark_test.go create mode 100644 internal/processing/status/unbookmark.go create mode 100644 internal/processing/status/unbookmark_test.go diff --git a/ROADMAP.md b/ROADMAP.md index 937f1bb9d..3acab31bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,8 +152,8 @@ Crossed out - will not be implemented / will be stubbed only. - [ ] /api/v1/statuses/:id/unmute POST (Unmute notifications on a status) - [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile) - [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile) - - ~~/api/v1/statuses/:id/bookmark POST (Bookmark a status)~~ - - ~~/api/v1/statuses/:id/unbookmark POST (Undo a bookmark)~~ + - [x] /api/v1/statuses/:id/bookmark POST (Bookmark a status) + - [x] /api/v1/statuses/:id/unbookmark POST (Undo a bookmark) - [x] Media - [x] /api/v1/media POST (Upload a media attachment) - [x] /api/v1/media/:id GET (Get a media attachment) @@ -242,8 +242,8 @@ Crossed out - will not be implemented / will be stubbed only. - ~~Suggestions~~ - ~~/api/v1/suggestions GET (Get suggested accounts to follow)~~ - ~~/api/v1/suggestions/:account_id DELETE (Delete a suggestion)~~ - - ~~Bookmarks~~ - - ~~/api/v1/bookmarks GET (See bookmarked statuses)~~ + - [x] Bookmarks + - [x] /api/v1/bookmarks GET (See bookmarked statuses) ### Non-API tasks diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index a90409344..ac2b1f303 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3326,6 +3326,34 @@ paths: summary: Get an array of accounts that requesting account has blocked. tags: - blocks + /api/v1/bookmarks: + get: + description: Get an array of statuses bookmarked in the instance + operationId: bookmarksGet + produces: + - application/json + responses: + "200": + description: Array of bookmarked statuses + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/status' + type: array + "401": + description: unauthorized + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:bookmarks + tags: + - bookmarks /api/v1/custom_emojis: get: operationId: customEmojisGet @@ -4111,6 +4139,40 @@ paths: summary: View status with the given ID. tags: - statuses + /api/v1/statuses/{id}/bookmark: + post: + operationId: statusBookmark + parameters: + - description: Target status ID. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - write:statuses + summary: Bookmark status with the given ID. + tags: + - statuses /api/v1/statuses/{id}/context: get: description: 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. @@ -4285,6 +4347,40 @@ paths: summary: View accounts that have reblogged/boosted the target status. tags: - statuses + /api/v1/statuses/{id}/unbookmark: + post: + operationId: statusUnbookmark + parameters: + - description: Target status ID. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - write:statuses + summary: Unbookmark status with the given ID. + tags: + - statuses /api/v1/statuses/{id}/unfavourite: post: operationId: statusUnfave diff --git a/internal/api/client/bookmarks/bookmarks.go b/internal/api/client/bookmarks/bookmarks.go new file mode 100644 index 000000000..492b7364c --- /dev/null +++ b/internal/api/client/bookmarks/bookmarks.go @@ -0,0 +1,50 @@ +/* + 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 . +*/ + +package bookmarks + +import ( + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // BasePath is the base path for serving the bookmarks API + BasePath = "/api/v1/bookmarks" +) + +// Module implements the ClientAPIModule interface for everything related to bookmarks +type Module struct { + processor processing.Processor +} + +// New returns a new emoji module +func New(processor processing.Processor) api.ClientModule { + return &Module{ + processor: processor, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodGet, BasePath, m.BookmarksGETHandler) + return nil +} diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go new file mode 100644 index 000000000..b4a4bdfb1 --- /dev/null +++ b/internal/api/client/bookmarks/bookmarks_test.go @@ -0,0 +1,148 @@ +/* + 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 . +*/ + +package bookmarks_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/bookmarks" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "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/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type BookmarkTestSuite 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 *status.Module + bookmarkModule *bookmarks.Module +} + +func (suite *BookmarkTestSuite) 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 *BookmarkTestSuite) 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 = status.New(suite.processor).(*status.Module) + suite.bookmarkModule = bookmarks.New(suite.processor).(*bookmarks.Module) + + suite.NoError(suite.processor.Start()) +} + +func (suite *BookmarkTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *BookmarkTestSuite) TestGetBookmark() { + 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(status.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + suite.bookmarkModule.BookmarksGETHandler(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) + + var statuses []model.Status + err = json.Unmarshal(b, &statuses) + suite.NoError(err) + + suite.Equal(1, len(statuses)) +} + +func TestBookmarkTestSuite(t *testing.T) { + suite.Run(t, new(BookmarkTestSuite)) +} diff --git a/internal/api/client/bookmarks/bookmarksget.go b/internal/api/client/bookmarks/bookmarksget.go new file mode 100644 index 000000000..dafc896ef --- /dev/null +++ b/internal/api/client/bookmarks/bookmarksget.go @@ -0,0 +1,107 @@ +package bookmarks + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +const ( + // LimitKey is for setting the return amount limit for eg., requesting an account's statuses + LimitKey = "limit" + + // MaxIDKey is for specifying the maximum ID of the bookmark to retrieve. + MaxIDKey = "max_id" + // MinIDKey is for specifying the minimum ID of the bookmark to retrieve. + MinIDKey = "min_id" +) + +// BookmarksGETHandler swagger:operation GET /api/v1/bookmarks bookmarksGet +// +// Get an array of statuses bookmarked in the instance +// +// --- +// tags: +// - bookmarks +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:bookmarks +// +// responses: +// '200': +// description: Array of bookmarked statuses +// schema: +// type: array +// items: +// "$ref": "#/definitions/status" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '401': +// description: unauthorized +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) BookmarksGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + limit := 30 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + resp, errWithCode := m.processor.BookmarksGet(c.Request.Context(), authed, maxID, minID, limit) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go index 67f046abd..dc32ae9b5 100644 --- a/internal/api/client/status/status.go +++ b/internal/api/client/status/status.go @@ -96,6 +96,9 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler) r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler) + r.AttachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler) + r.AttachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler) + r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) diff --git a/internal/api/client/status/statusbookmark.go b/internal/api/client/status/statusbookmark.go new file mode 100644 index 000000000..983becd72 --- /dev/null +++ b/internal/api/client/status/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 . +*/ + +package status + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "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 { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/status/statusbookmark_test.go b/internal/api/client/status/statusbookmark_test.go new file mode 100644 index 000000000..d3da4f297 --- /dev/null +++ b/internal/api/client/status/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 . +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type 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(status.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: status.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/status/statusunbookmark.go b/internal/api/client/status/statusunbookmark.go new file mode 100644 index 000000000..aa090f8c9 --- /dev/null +++ b/internal/api/client/status/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 . +*/ + +package status + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "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 { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/status/statusunbookmark_test.go b/internal/api/client/status/statusunbookmark_test.go new file mode 100644 index 000000000..09a18ab9b --- /dev/null +++ b/internal/api/client/status/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 . +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type 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(status.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: status.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/db/account.go b/internal/db/account.go index 7e7d1de43..015621632 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -73,6 +73,8 @@ type Account interface { // or replies. GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error) + GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error) + GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error) // GetAccountLastPosted simply gets the timestamp of the most recent post by the account. diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 9f3fc8a16..01601e540 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -442,6 +442,38 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, return a.statusesFromIDs(ctx, statusIDs) } +func (a *accountDB) GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) { + bookmarks := []*gtsmodel.StatusBookmark{} + + q := a.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Order("status_bookmark.id DESC"). + Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID) + + if accountID == "" { + return nil, errors.New("must provide an account") + } + + if limit != 0 { + q = q.Limit(limit) + } + + if maxID != "" { + q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID) + } + + if minID != "" { + q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID) + } + + if err := q.Scan(ctx, &bookmarks); err != nil { + return nil, a.conn.ProcessError(err) + } + + return bookmarks, nil +} + func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) { blocks := []*gtsmodel.Block{} diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index bf85f14f4..e63956f87 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -208,6 +208,12 @@ func (suite *AccountTestSuite) TestInsertAccountWithDefaults() { suite.False(*newAccount.HideCollections) } +func (suite *AccountTestSuite) TestGettingBookmarksWithNoAccount() { + statuses, err := suite.db.GetBookmarks(context.Background(), "", 10, "", "") + suite.Error(err) + suite.Nil(statuses) +} + func TestAccountTestSuite(t *testing.T) { suite.Run(t, new(AccountTestSuite)) } diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index 3eccbc27d..4c29621ed 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -64,6 +64,9 @@ type Processor interface { // WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only // statuses which are suitable for showing on the public web profile of an account. WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) + // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for + // the account given in authed. + BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode) // FollowersGet fetches a list of the target account's followers. FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // FollowingGet fetches a list of the accounts that target account is following. diff --git a/internal/processing/account/getbookmarks.go b/internal/processing/account/getbookmarks.go new file mode 100644 index 000000000..0b15806c3 --- /dev/null +++ b/internal/processing/account/getbookmarks.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 . +*/ + +package account + +import ( + "context" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode) { + if requestingAccount == nil { + return nil, gtserror.NewErrorForbidden(fmt.Errorf("cannot retrieve bookmarks without a requesting account")) + } + + bookmarks, err := p.db.GetBookmarks(ctx, requestingAccount.ID, limit, maxID, minID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(bookmarks) + filtered := make([]*gtsmodel.Status, 0, len(bookmarks)) + nextMaxIDValue := "" + prevMinIDValue := "" + for i, b := range bookmarks { + s, err := p.db.GetStatusByID(ctx, b.StatusID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + visible, err := p.filter.StatusVisible(ctx, s, requestingAccount) + if err == nil && visible { + if i == count-1 { + nextMaxIDValue = b.ID + } + + if i == 0 { + prevMinIDValue = b.ID + } + + filtered = append(filtered, s) + } + } + + count = len(filtered) + + if count == 0 { + return util.EmptyPageableResponse(), nil + } + + items := []interface{}{} + for _, s := range filtered { + item, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) + } + items = append(items, item) + } + + return util.PackagePageableResponse(util.PageableResponseParams{ + Items: items, + Path: "/api/v1/bookmarks", + NextMaxIDValue: nextMaxIDValue, + PrevMinIDValue: prevMinIDValue, + Limit: limit, + ExtraQueryParams: []string{}, + }) +} diff --git a/internal/processing/bookmark.go b/internal/processing/bookmark.go new file mode 100644 index 000000000..c3bcfca4a --- /dev/null +++ b/internal/processing/bookmark.go @@ -0,0 +1,31 @@ +/* + 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 . +*/ + +package processing + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { + return p.accountProcessor.BookmarksGet(ctx, authed.Account, limit, maxID, minID) +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 22fb7b2b7..88b0f5594 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -146,6 +146,9 @@ type Processor interface { // CustomEmojisGet returns an array of info about the custom emojis on this server CustomEmojisGet(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode) + // BookmarksGet returns a pageable response of statuses that have been bookmarked + BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) + // FileGet handles the fetching of a media attachment file via the fileserver. FileGet(ctx context.Context, authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, gtserror.WithCode) @@ -202,6 +205,10 @@ type Processor interface { StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusGetContext returns the context (previous and following posts) from the given status ID StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) + // StatusBookmark process a bookmark for a status + StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // StatusUnbookmark removes a bookmark for a status + StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters. HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) diff --git a/internal/processing/status.go b/internal/processing/status.go index b2f222971..808079c97 100644 --- a/internal/processing/status.go +++ b/internal/processing/status.go @@ -65,3 +65,11 @@ func (p *processor) StatusUnfave(ctx context.Context, authed *oauth.Auth, target func (p *processor) StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { return p.statusProcessor.Context(ctx, authed.Account, targetStatusID) } + +func (p *processor) StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + return p.statusProcessor.Bookmark(ctx, authed.Account, targetStatusID) +} + +func (p *processor) StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + return p.statusProcessor.Unbookmark(ctx, authed.Account, targetStatusID) +} diff --git a/internal/processing/status/bookmark.go b/internal/processing/status/bookmark.go new file mode 100644 index 000000000..0f13fbacf --- /dev/null +++ b/internal/processing/status/bookmark.go @@ -0,0 +1,86 @@ +/* + 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 . +*/ + +package status + +import ( + "context" + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +func (p *processor) Bookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + if targetStatus.Account == nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + } + visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // first check if the status is already bookmarked, if so we don't need to do anything + newBookmark := true + gtsBookmark := >smodel.StatusBookmark{} + if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil { + // we already have a bookmark for this status + newBookmark = false + } + + if newBookmark { + thisBookmarkID, err := id.NewULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // we need to create a new bookmark in the database + gtsBookmark := >smodel.StatusBookmark{ + ID: thisBookmarkID, + AccountID: requestingAccount.ID, + Account: requestingAccount, + TargetAccountID: targetStatus.AccountID, + TargetAccount: targetStatus.Account, + StatusID: targetStatus.ID, + Status: targetStatus, + } + + if err := p.db.Put(ctx, gtsBookmark); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting bookmark in database: %s", err)) + } + } + + // return the apidon representation of the target status + apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return apiStatus, nil +} diff --git a/internal/processing/status/bookmark_test.go b/internal/processing/status/bookmark_test.go new file mode 100644 index 000000000..ed1f9c774 --- /dev/null +++ b/internal/processing/status/bookmark_test.go @@ -0,0 +1,48 @@ +/* + 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 . +*/ + +package status_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" +) + +type StatusBookmarkTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusBookmarkTestSuite) TestBookmark() { + ctx := context.Background() + + // bookmark a status + bookmarkingAccount1 := suite.testAccounts["local_account_1"] + targetStatus1 := suite.testStatuses["admin_account_status_1"] + + bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID) + suite.NoError(err) + suite.NotNil(bookmark1) + suite.True(bookmark1.Bookmarked) + suite.Equal(targetStatus1.ID, bookmark1.ID) +} + +func TestStatusBookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusBookmarkTestSuite)) +} diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go index c63769c76..d31b69b38 100644 --- a/internal/processing/status/status.go +++ b/internal/processing/status/status.go @@ -54,6 +54,10 @@ type Processor interface { Unfave(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // Context returns the context (previous and following posts) from the given status ID Context(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) + // Bookmarks a status + Bookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Removes a bookmark for a status + Unbookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) /* PROCESSING UTILS diff --git a/internal/processing/status/unbookmark.go b/internal/processing/status/unbookmark.go new file mode 100644 index 000000000..ef5962495 --- /dev/null +++ b/internal/processing/status/unbookmark.go @@ -0,0 +1,69 @@ +/* + 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 . +*/ + +package status + +import ( + "context" + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Unbookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + if targetStatus.Account == nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + } + visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // first check if the status is already bookmarked + toUnbookmark := false + gtsBookmark := >smodel.StatusBookmark{} + if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil { + // we already have a bookmark for this status + toUnbookmark = true + } + + if toUnbookmark { + if err := p.db.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err)) + } + } + + // return the apidon representation of the target status + apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return apiStatus, nil +} diff --git a/internal/processing/status/unbookmark_test.go b/internal/processing/status/unbookmark_test.go new file mode 100644 index 000000000..38a60d776 --- /dev/null +++ b/internal/processing/status/unbookmark_test.go @@ -0,0 +1,54 @@ +/* + 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 . +*/ + +package status_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" +) + +type StatusUnbookmarkTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusUnbookmarkTestSuite) TestUnbookmark() { + ctx := context.Background() + + // bookmark a status + bookmarkingAccount1 := suite.testAccounts["local_account_1"] + targetStatus1 := suite.testStatuses["admin_account_status_1"] + + bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID) + suite.NoError(err) + suite.NotNil(bookmark1) + suite.True(bookmark1.Bookmarked) + suite.Equal(targetStatus1.ID, bookmark1.ID) + + bookmark2, err := suite.status.Unbookmark(ctx, bookmarkingAccount1, targetStatus1.ID) + suite.NoError(err) + suite.NotNil(bookmark2) + suite.False(bookmark2.Bookmarked) + suite.Equal(targetStatus1.ID, bookmark1.ID) +} + +func TestStatusUnbookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusUnbookmarkTestSuite)) +} diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index a3f134312..308df5fc2 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { b, err := json.Marshal(apiStatus) suite.NoError(err) - suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) + suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() { diff --git a/testrig/db.go b/testrig/db.go index 508f10b76..3e34344bc 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -261,6 +261,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { } } + for _, v := range NewTestBookmarks() { + if err := db.Put(ctx, v); err != nil { + log.Panic(err) + } + } + if err := db.CreateInstanceAccount(ctx); err != nil { log.Panic(err) } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index b34c1a1d9..cbaa855e8 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -2251,6 +2251,26 @@ func NewTestFediStatuses() map[string]vocab.ActivityStreamsNote { } } +// NewTestBookmarks returns a map of gts model bookmarks, keyed in the format [bookmarking_account]_[target_status] +func NewTestBookmarks() map[string]*gtsmodel.StatusBookmark { + return map[string]*gtsmodel.StatusBookmark{ + "local_account_1_admin_account_status_1": { + ID: "01F8MHD2QCZSZ6WQS2ATVPEYJ9", + CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"), + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1 + TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account + StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", // admin account status 1 + }, + "admin_account_local_account_1_status_1": { + ID: "01F8Q0486ANTDWKG02A7DS1Q24", + CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"), + AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account + TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1 + StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY", // local account status 1 + }, + } +} + // NewTestDereferenceRequests returns a map of incoming dereference requests, with their signatures. func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { var sig, digest, date string