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